Caught while wiring up an HTTP-driven deploy that runs the user's
canonical deploy.sh from a CGI:
- HTTP_AS_ROOT=1 — opt out of busybox httpd's `-u USER` drop so the
CGI (and the deploy.sh it runs) keep root supplementary groups.
Required because busybox httpd does setuid/setgid but not
setgroups; dropping to agent loses the dockerhost group and the
CGI can't reach /var/run/docker.sock. Bastion already has the
socket = host root, so this doesn't widen the envelope.
- chown -R …/.ssh — make it best-effort. With ssh creds mounted
read-only (id_rsa, known_hosts), the chown -R failed under
`set -e` and killed boot. The dir + the file we wrote are what
matter; anything bind-mounted in is the caller's business.
- git config --system --add safe.directory '*' — silence
'detected dubious ownership' when the CGI runs as root over a
host-uid-owned repo (standard bind-mount-into-bastion case).
- GIT_SSH_COMMAND auto-export — when a key is mounted at
/home/agent/.ssh/id_rsa, the wrapper sets git's ssh invocation
to point at it explicitly. Required because setting HOME alone
doesn't make root-uid ssh follow ~/.ssh/{id_rsa,known_hosts}.
|
||
|---|---|---|
| config | ||
| scripts | ||
| .dockerignore | ||
| Dockerfile | ||
| README.md | ||
| docker-bake.hcl | ||
README.md
docker-bastion
A minimal SSH + HTTP bastion for routing one preconfigured command per authenticated session. Authenticate by SSH key or HTTP bearer token, the container runs whatever you point FORCE_COMMAND at — docker exec into a sibling container, ./deploy.sh, nginx -s reload, anything — and streams the output back.
Why it exists: giving an agent or CI bot docker exec access usually means handing them the docker socket and trusting their entire toolchain not to misbehave. A bastion with a hard-coded FORCE_COMMAND is the inverse: the credential authorizes one specific thing, the surface is sshd + busybox httpd, and the same image works for a dozen different roles by varying FORCE_COMMAND.
Available Tags
| Tag | Base | Notes |
|---|---|---|
blaxsoftware/bastion:latest |
alpine 3.21 | Default tag, follows alpine releases |
blaxsoftware/bastion:alpine3.21 |
alpine 3.21 | Pinned alpine version |
Quick Start — drop into a WordPress container
The most common use: give a deploy agent SSH-shaped access into a running WordPress container. Every session lands inside the wordpress-app container's bash; clients can run WP-CLI commands, edit config, debug — same UX as ssh user@host against a VPS, but scoped to one container.
services:
# The bastion. SSH on 2222, HTTP behind traefik on https://deploy-wp.example.com.
bastion:
image: blaxsoftware/bastion:latest
restart: unless-stopped
environment:
# REQUIRED — the single command that runs on every authenticated session.
# Shell metacharacters work: &&, ||, pipes, cd, redirects.
FORCE_COMMAND: "docker exec -it wordpress-app bash"
# OPTIONAL — enables the HTTP endpoint at /cgi-bin/run with basic auth.
# Value is "user:password". Works with `curl https://user:pass@host/…`.
# Without this (and without HTTP_TOKEN), the bastion is SSH-only.
HTTP_BASIC_AUTH: "${BASTION_HTTP_BASIC_AUTH}"
# Alternative: HTTP_TOKEN="$(openssl rand -hex 32)" for Bearer auth.
# Pick one — they're mutually exclusive (Basic wins if both are set).
volumes:
# REQUIRED when FORCE_COMMAND talks to docker (docker exec, docker compose, etc).
# Mounts the host's daemon socket so docker-cli inside the bastion reaches it.
- /var/run/docker.sock:/var/run/docker.sock
# OPTIONAL — host-sourced authorized_keys. Your laptop's keys, or anything
# outside the repo. Read-only mount.
- ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
# OPTIONAL — repo-sourced authorized_keys. CI / deploy-bot keys committed
# alongside the project. At least one of these two must exist or the
# container refuses to start.
- ./docker/bastion/authorized_keys:/etc/bastion/authorized_keys.repo:ro
# REQUIRED — host keys persist across rebuilds. Bind mount, NEVER a named
# volume; `docker compose down -v` would wipe a named volume and clients
# would see "REMOTE HOST IDENTIFICATION HAS CHANGED" after every redeploy.
- ./docker-data/bastion/keys:/etc/ssh/keys
ports:
# OPTIONAL — expose SSH on the host directly. Skip this entirely if you
# only want the HTTP path through traefik.
- "2222:22"
labels:
# OPTIONAL — traefik HTTP route. Visit https://deploy-wp.example.com/cgi-bin/run
# with `Authorization: Bearer $BASTION_HTTP_TOKEN` to invoke FORCE_COMMAND.
# Remove these labels if you don't want the HTTP path published.
traefik.enable: "true"
traefik.docker.network: "web"
traefik.http.routers.bastion.rule: "Host(`deploy-wp.example.com`)"
traefik.http.routers.bastion.entrypoints: "websecure"
traefik.http.routers.bastion.tls: "true"
traefik.http.services.bastion.loadbalancer.server.port: "8080"
networks: [web]
# Your actual WordPress container — bastion's FORCE_COMMAND targets it by name.
wordpress-app:
image: wordpress:latest
container_name: wordpress-app
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wp
WORDPRESS_DB_USER: wp
WORDPRESS_DB_PASSWORD: "${DB_PASSWORD}"
volumes:
- ./docker-data/wordpress:/var/www/html
networks: [web]
networks:
web:
external: true
From the client:
# Interactive shell inside the wp container — feels exactly like ssh-into-vps
ssh -p 2222 agent@your-host
# Or trigger from a URL — basic-auth protected, output streams back
curl https://user:pass@deploy-wp.example.com/cgi-bin/run
Quick Start — reload nginx on a webhook
A scoped bastion that does exactly one thing: test the new nginx config and reload if it passes. The HTTP path lets a CI job (GitHub Action, Forgejo runner, anything that can curl) trigger a reload after pushing new configs to disk — no SSH keys to provision in CI.
services:
bastion:
image: blaxsoftware/bastion:latest
restart: unless-stopped
environment:
# `nginx -t` exits non-zero on a syntax error; `&&` short-circuits so a
# broken config never gets applied. The exit code propagates back to
# the HTTP client (which sees the connection close mid-stream on failure).
FORCE_COMMAND: "docker exec nginx-app nginx -t && docker exec nginx-app nginx -s reload"
HTTP_TOKEN: "${BASTION_HTTP_TOKEN}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# SSH path stays available for human debugging — same key, same scope.
- ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
- ./docker-data/bastion/keys:/etc/ssh/keys
labels:
traefik.enable: "true"
traefik.docker.network: "web"
traefik.http.routers.bastion-nginx.rule: "Host(`reload-nginx.example.com`)"
traefik.http.routers.bastion-nginx.entrypoints: "websecure"
traefik.http.routers.bastion-nginx.tls: "true"
traefik.http.services.bastion-nginx.loadbalancer.server.port: "8080"
networks: [web]
nginx-app:
image: nginx:alpine
container_name: nginx-app
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro
networks: [web]
networks:
web:
external: true
CI snippet:
# After updating nginx.conf on disk:
curl --fail-with-body -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \
https://reload-nginx.example.com/cgi-bin/run
curl --fail-with-body makes the CI step fail (non-zero exit) if the bastion returns 4xx/5xx, with the body printed — so a nginx -t syntax error in the new config shows up in the CI log without extra wiring.
Two channels, two shapes
| Channel | Best for | TTY? | Streaming? |
|---|---|---|---|
| SSH | Interactive (docker exec -it) or scripts |
yes | yes |
| HTTP | Scripts only — no TTY | no | yes (chunked / close-delimited) |
Interactive commands (docker exec -it app bash) over HTTP fail because there's no TTY — use SSH for those. Both channels stream output line-by-line; both close as soon as FORCE_COMMAND exits and the exit code propagates (SSH: to the client; HTTP: nonzero closes the response mid-stream).
The client cannot override the command. SSH_ORIGINAL_COMMAND and HTTP request bodies are intentionally ignored.
Authorized keys — three sources, two flavors
sshd consults three sources on every authentication attempt:
| Source | Read when | Mount UX |
|---|---|---|
/etc/bastion/users.d/*.pub |
live, every login | Drop one .pub file per user — users.d/alice.pub, users.d/bob.pub. No restart to add/revoke. |
/etc/bastion/authorized_keys.host |
merged at boot | Single file from the host — ~/.ssh/authorized_keys. |
/etc/bastion/authorized_keys.repo |
merged at boot | Single file from the repo — ./docker/bastion/authorized_keys. |
Recommended: users.d/ — one file per identity, dropped in via a host bind mount, adds and revokes immediately. The two file-based sources stay for backward compatibility and for the "one big committed file" pattern.
Zero authorized keys is now a warning, not a startup failure — the bastion runs but every SSH attempt fails with publickey denied until you drop a key in.
Environment variables
| Variable | Default | Description |
|---|---|---|
FORCE_COMMAND |
(required) | The command run on every authenticated session. Shell metacharacters OK. |
HTTP_BASIC_AUTH |
(unset) | Enables HTTP with Basic auth. Value is user:password. Works with curl -u, curl https://user:pass@host/…, and browser URL bars. |
HTTP_TOKEN |
(unset) | Enables HTTP with Bearer auth. Clients send Authorization: Bearer <this>. Mutually exclusive with HTTP_BASIC_AUTH (basic takes precedence). |
HTTP_PORT |
8080 |
Port for the HTTP listener (when either auth var is set). |
SSH_PORT |
22 |
Port for sshd inside the container. |
AUTHORIZED_KEYS_DIR |
/etc/bastion/users.d |
Directory of *.pub files, live-read by sshd via AuthorizedKeysCommand. Drop a file → next login picks it up. |
AUTHORIZED_KEYS_HOST |
/etc/bastion/authorized_keys.host |
Single-file source, merged at boot. Optional / legacy. |
AUTHORIZED_KEYS_REPO |
/etc/bastion/authorized_keys.repo |
Single-file source, merged at boot. Optional / legacy. |
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, global
ForceCommanddirective (clients cannot bypass). - busybox httpd (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when
HTTP_TOKENis set. - docker-cli + docker-cli-compose — so
FORCE_COMMANDcan target containers through a mounted docker socket. Group membership is auto-aligned to the host socket's GID at boot. - tini — PID 1, signal handling, zombie reaping.
- bash, ca-certificates, tzdata.
Total image: ~105 MB. Most of that is docker-cli (~50 MB) and docker-cli-compose (~25 MB).
Security model
The security boundary is the authorized_keys file (SSH) and the HTTP_TOKEN (HTTP), plus the ForceCommand wrapper. Once a key or bearer token authenticates, the session runs exactly one command — there is no fallback shell. The bastion holds the docker socket, which is host-root-equivalent, so the only thing standing between a remote attacker and host root is the auth layer + your key/token hygiene.
Practical checklist:
- Key-only SSH, no passwords — enforced in
sshd_config. - HTTP requires auth — either basic auth (via busybox httpd's
-cconf, withREMOTE_USERset on the authenticated CGI) or bearer token (validated by the CGI script). No anonymous path. - No agent / TCP / X11 forwarding, no port tunnels — enforced in
sshd_config. - ForceCommand cannot be bypassed. Clients can request any command (
ssh user@host arbitrary-thing); sshd ignores it and runs/etc/bastion/force-command.SSH_ORIGINAL_COMMANDis dropped. The bastion user's login shell is/bin/sh(notnologin— that would break ForceCommand itself, since sshd invokes the user's shell asshell -c "<forced-command>"), but it has no path to anything other than the wrapper. PermitUserEnvironment no,PermitUserRC no— clients cannot inject env vars or rc files.- Bind host ports to
127.0.0.1or hide them behind traefik+TLS unless you genuinely need them publicly open on raw TCP. The traefik path withentrypoints: websecureandtls: trueis the recommended public exposure. - Rotate
HTTP_TOKENregularly. Generate withopenssl rand -hex 32, store in.env, never commit. - Keep alpine + openssh patched. An unauth RCE in sshd or httpd here means host root.
apk upgradein a rebuild cycle. - Lock down siblings. Anyone who can
docker execinto the app via this bastion can alsodocker execintomysql/redis/etc through the same socket.cap_drop: [ALL]andno-new-privileges: trueon every sibling caps the blast radius. - One bastion per role. Don't reuse a single
FORCE_COMMANDfor both interactive shells and deploy automation — separate ports and separate token/key sets make audit trails meaningful.
Architecture
start-container (entrypoint)
├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount)
├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys
├─ write /etc/bastion/force-command wrapper from $FORCE_COMMAND
├─ align docker socket group membership to host GID (if socket is mounted)
├─ start httpd → /var/www/cgi-bin/run (if $HTTP_TOKEN is set)
└─ exec sshd -D -e
ssh client
└─ key auth as `agent`
└─ ForceCommand /etc/bastion/force-command
└─ exec sh -c "$FORCE_COMMAND"
http client
└─ Authorization: Bearer <HTTP_TOKEN>
└─ /var/www/cgi-bin/run validates token
└─ exec /etc/bastion/force-command
└─ exec sh -c "$FORCE_COMMAND"
License
MIT.