6.8 KiB
docker-bastion
A minimal SSH bastion that authenticates by key and runs exactly one preconfigured command on every login. No fallback shell, no interactive choice — the agent connecting via SSH gets dropped straight into whatever you point FORCE_COMMAND at.
Typical uses:
- Give an agent (or a human) an SSH-shaped door into a running container — connection lands inside
docker exec -it app bashand behaves like SSH'ing into the app. - Give a deploy bot an SSH-shaped door that runs
./deploy.shon connect, streams output, and disconnects when the script exits.
Same image, different FORCE_COMMAND per service.
Image
| Tag | Base | Size (est.) |
|---|---|---|
blaxsoftware/bastion:latest |
alpine 3.21 | ~65 MB |
Quick Start
services:
ssh-app:
image: blaxsoftware/bastion:latest
ports:
- "2222:22"
environment:
FORCE_COMMAND: "docker exec -it learnatc-app-1 bash"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
- ./docker/bastion/authorized_keys:/etc/bastion/authorized_keys.repo:ro
- ./docker-data/bastion-app/keys:/etc/ssh/keys
restart: unless-stopped
ssh-deploy:
image: blaxsoftware/bastion:latest
ports:
- "2223:22"
environment:
FORCE_COMMAND: "cd /workspace && ./deploy.sh"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace:ro
- ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
- ./docker-data/bastion-deploy/keys:/etc/ssh/keys
restart: unless-stopped
Host keys live in
./docker-data/bastion-*/keys/as bind mounts — never named volumes.docker compose down -vthen can't wipe them, and the client doesn't see "REMOTE HOST IDENTIFICATION HAS CHANGED" after a rebuild. Gitignoredocker-data/in the surrounding repo.
Then from the client:
ssh -p 2222 agent@your-host # → drops into bash inside the app container
ssh -p 2223 agent@your-host # → streams ./deploy.sh, disconnects on exit
Authorized Keys — two sources, merged
At boot the entrypoint concatenates whichever of these files exist into the agent's authorized_keys. At least one must exist or the container refuses to start.
| File | Typical mount |
|---|---|
/etc/bastion/authorized_keys.host |
~/.ssh/authorized_keys from the docker host |
/etc/bastion/authorized_keys.repo |
./docker/bastion/authorized_keys in the repo |
Mount either, both, or neither — but neither = container exits at startup with a clear error.
How FORCE_COMMAND behaves
FORCE_COMMAND is run via sh -c, so shell metacharacters work — &&, ||, pipes, cd, redirects.
- Interactive command (
docker exec -it app bash) — SSH allocates a PTY by default forssh user@host,docker exec -itinherits it, the inner bash is interactive. Session ends when the user typesexit. - Script command (
cd /workspace && ./deploy.sh) — output streams back over SSH, the session closes the moment the script exits. Exit code propagates to the SSH client.
The client cannot override the command. SSH_ORIGINAL_COMMAND is ignored.
Environment Variables
| Variable | Default | Description |
|---|---|---|
FORCE_COMMAND |
(required) | The command run on every authenticated session. Shell metacharacters OK. |
AUTHORIZED_KEYS_HOST |
/etc/bastion/authorized_keys.host |
Path to the host-sourced authorized_keys (mount it here). |
AUTHORIZED_KEYS_REPO |
/etc/bastion/authorized_keys.repo |
Path to the repo-sourced authorized_keys (mount it here). |
SSH_PORT |
22 |
Port sshd listens on inside the container. |
Build Args
| Arg | Default | Description |
|---|---|---|
ALPINE_VERSION |
3.21 |
Alpine base image tag. |
SSH_UID |
1000 |
UID of the bastion agent user. |
SSH_GID |
1000 |
GID of the bastion agent group. |
What's Inside
- openssh-server — hardened config: key-only auth, no forwarding, no PAM, no user env,
/sbin/nologinshell, globalForceCommand. - docker-cli + docker-cli-compose — so
FORCE_COMMANDcan target containers via a mounted docker socket. Group membership is auto-aligned at boot to the host socket's GID. - tini — PID 1, signal handling.
- bash, ca-certificates, tzdata.
Security Model
The security boundary is the authorized_keys file and the ForceCommand wrapper. Once a key authenticates, the session is hard-pinned to exactly one command. The bastion has the docker socket — equivalent to host root — so the only thing standing between a remote attacker and host root is sshd + your key hygiene.
Practical checklist:
- Key-only auth, no passwords — enforced in
sshd_config. - No agent / tcp / x11 forwarding, no port tunnels — enforced in
sshd_config. - Login shell is
/sbin/nologin— no fallback ifForceCommandsomehow misfires. PermitUserEnvironment no,PermitUserRC no— clients cannot inject env or rc files.- Bind the host port to
127.0.0.1or behind a firewall / VPN unless you actually need it public. - Keep openssh patched —
apk upgradein a rebuild cycle; an unauth sshd RCE here would mean host root. - Lock down siblings — anyone who can
docker execinto the app can alsodocker execinto the mysql/redis container via the same socket.cap_drop: [ALL]andno-new-privilegeson siblings caps the blast radius.
Architecture
start-container (entrypoint)
├─ generate host keys (idempotent, persisted via /etc/ssh/keys volume)
├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys
├─ write /etc/bastion/force-command wrapper from $FORCE_COMMAND
├─ align docker socket group membership (if socket is mounted)
└─ exec sshd -D -e
ssh client
└─ key auth as `agent`
└─ ForceCommand /etc/bastion/force-command
└─ exec sh -c "$FORCE_COMMAND"