A disposable jail container that bind-mounts only one directory; the bastion's FORCE_COMMAND drops every SSH session into an interactive shell inside it. The jail's own root fs is throwaway image data, so the only host data reachable over the session is the mounted directory. Documents the docker-socket tradeoff and the read-only / no-socket hardened variants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| config | ||
| examples | ||
| scripts | ||
| .dockerignore | ||
| Dockerfile | ||
| README.md | ||
| docker-bake.hcl | ||
README.md
docker-bastion
A minimal SSH + HTTP bastion for routing commands per authenticated session. Authenticate by SSH key or HTTP bearer token, and the container either runs one preconfigured command (FORCE_COMMAND — docker exec into a sibling, ./deploy.sh, nginx -s reload) or, in broker mode, runs whatever the client asks for as long as it matches a regex allowlist (ALLOWED_COMMANDS) — then 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.
Quick Start — manage docker-mailserver (broker mode)
The two examples above hard-code one command. Broker mode is the inverse: the client supplies the command and the bastion runs it only if it matches a regex allowlist. This is how you give a management UI (a Nuxt "mail manager", a cron job, a bot) a menu of allowed operations without handing it a shell.
Here one bastion fronts docker-mailserver's setup CLI. The app sends email add jane@example.com <pw> over SSH on the internal network; the bastion validates it and runs docker exec -i mailserver setup email add jane@example.com <pw>.
services:
bastion-mail:
image: blaxsoftware/bastion:latest
restart: unless-stopped
environment:
# Trusted prefix prepended to every validated request, so clients
# send clean `email add …` and never see the docker plumbing.
COMMAND_PREFIX: "docker exec -i mailserver setup"
# The whitelist. A YAML block scalar reads like a string array:
# one extended-regex (ERE) rule per line. A request is allowed only
# if it matches a rule WHOLE-LINE (anchored). Matched commands run
# WITHOUT a shell — ; | & $() are literal args, never operators —
# so keep argument classes tight ([^ ]+, not .*).
ALLOWED_COMMANDS: |
email add [^ ]+@[^ ]+ [^ ]+
email update [^ ]+@[^ ]+ [^ ]+
email del [^ ]+@[^ ]+
email list
alias add [^ ]+@[^ ]+ [^ ]+
alias del [^ ]+@[^ ]+ [^ ]+
alias list
quota set [^ ]+@[^ ]+ [0-9]+[KMGT]?
quota del [^ ]+@[^ ]+
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-data/bastion/users.d:/etc/bastion/users.d
- ./docker-data/bastion/keys:/etc/ssh/keys
networks: [web] # same network as the `mailserver` container
networks:
web:
external: true
From the mail-manager (same docker network, no host port needed):
# allowed → runs `docker exec -i mailserver setup email add …`
ssh agent@bastion-mail "email add jane@example.com $(openssl rand -base64 18 | tr -d /+=)"
ssh agent@bastion-mail "email list"
# refused → exits 126, nothing runs
ssh agent@bastion-mail "email del jane@example.com; rm -rf /"
A complete copy-paste setup (compose + a live-editable allowed-commands.list) is in examples/docker-mailserver/.
Quick Start — a "fake VPS" scoped to one directory
Broker mode hands out a menu of commands. Sometimes you want the opposite: a
real interactive shell that feels like a VPS, but where the only thing on the
"server" is one directory — e.g. hand someone your docker-mailserver
directory to manage, and nothing else of the host.
The trick is a disposable jail container whose only real mount is that one
directory; the bastion's FORCE_COMMAND drops every session into a shell
inside it. The jail's own root filesystem is throwaway image data, not your
server — so the only host data reachable over SSH is the directory you mounted.
services:
# Disposable shell box. ONLY real host data inside it = the one directory.
dms-jail:
image: docker:27-cli # or build a richer image — see the example
container_name: dms-jail
restart: unless-stopped
working_dir: /opt/docker-mailserver
entrypoint: ["tail", "-f", "/dev/null"] # stay alive for `docker exec`
volumes:
# THE one directory. Keep host path == in-jail path so `docker compose`
# inside the jail resolves the stack's bind mounts to host paths.
- /opt/docker-mailserver:/opt/docker-mailserver
# Lets the shell drive `docker compose`/restart. Host-root-equivalent —
# drop this mount (here and below) for a true no-escape jail.
- /var/run/docker.sock:/var/run/docker.sock
networks: [web]
bastion-vps:
image: blaxsoftware/bastion:latest
restart: unless-stopped
depends_on: [dms-jail]
environment:
# Every SSH session becomes an interactive shell in the jail, in the
# directory. `-it` = TTY → use SSH (not the HTTP path) for this.
FORCE_COMMAND: "docker exec -it -w /opt/docker-mailserver dms-jail sh"
volumes:
- /var/run/docker.sock:/var/run/docker.sock # only to exec into the jail
- ./docker-data/bastion/users.d:/etc/bastion/users.d
- ./docker-data/bastion/keys:/etc/ssh/keys
ports:
- "2222:22"
networks: [web]
networks:
web:
external: true
ssh -p 2222 agent@your-host # lands you in the directory, real shell
The docker-socket tradeoff: giving the jail the socket (so docker compose
works) is host-root-equivalent — from that shell, docker run -v /:/host …
reaches the whole host. It's a practical one-directory VPS for trusted
operators, not a hard sandbox. For a true no-escape boundary, remove the
docker.sock mount from both services (and manage the stack via a separate
broker-mode bastion).
A complete copy-paste setup — with a richer jail image (bash, editors, git,
compose) and the read-only / no-socket variants spelled out — is in
examples/docker-mailserver-vps/.
Command broker mode
Set ALLOWED_COMMANDS (or mount /etc/bastion/allowed-commands.list) and the bastion switches from "one fixed command" to "any command the client asks for, if it passes the allowlist". FORCE_COMMAND is then optional and ignored.
How a request is judged
- The client-supplied command (SSH
SSH_ORIGINAL_COMMAND, or the HTTPX-Bastion-Commandheader) is taken verbatim. - Multi-line requests are rejected outright (so a benign first line can't smuggle a second).
- It is matched whole-line, anchored against each rule (
grep -Eqx). First match wins. - On a match: the (optional)
COMMAND_PREFIXand the request are word-split with globbing off andexec'd directly — there is nosh -c.;|&`$()<>become literal arguments, never shell operators. On no match: refused, exit126, logged as[broker] DENY: ….
Why shell-free execution matters. It means the regex is a containment boundary, not just a filter. Even a careless rule like email .* cannot escalate to command injection — the worst case is that setup receives a weird argument. (Contrast the sh -c path used by FORCE_COMMAND mode, where a metacharacter would be interpreted.)
Writing rules
- One ERE per line;
#comments and blank lines ignored; surrounding whitespace trimmed (so indented YAML block lines work). - Anchoring is implicit — write
email list, not^email list$(both work). - Use tight argument classes:
[^ ]+("a run of non-spaces") beats.*. Add alternation for fixed verbs:email (add|update|del|list). - Values that must reach the target intact cannot contain whitespace (word-splitting) or be quoted (no shell). Generate passwords/tokens from a space-free alphabet — e.g.
openssl rand -base64 24 | tr -d /+=.
Two sources, additive: ALLOWED_COMMANDS (env, snapshot at boot) + /etc/bastion/allowed-commands.list (bind mount, re-read every request — edit without a restart).
SSH vs HTTP. SSH is the recommended transport (clean stdout/stderr separation). The HTTP path works too — POST/GET with Authorization + X-Bastion-Command: <cmd> — but merges the broker's stderr into the response body (the CGI uses 2>&1, same as the deploy examples).
Where the audit line goes. The broker prints [broker] ALLOW: … / [broker] DENY: … on the command's stderr — so over SSH it returns to the client on the channel's stderr stream, and over HTTP it is folded into the response body (2>&1). It does not land in the bastion container's own docker logs (sshd does not redirect a ForceCommand's stderr to the daemon log). If you want a container-side audit trail, tee it — e.g. have COMMAND_PREFIX/the wrapper append to a logfile, or run a syslog sidecar.
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 unless ALLOWED_COMMANDS set) |
FORCE_COMMAND mode: the command run on every authenticated session. Shell metacharacters OK. Ignored in broker mode. |
ALLOWED_COMMANDS |
(unset) | Broker mode: newline-separated ERE allowlist. Set this (or mount the file below) to enable broker mode. Each rule must match a request whole-line. |
COMMAND_PREFIX |
(unset) | Broker mode, optional: trusted prefix prepended to every validated request (e.g. docker exec -i mailserver setup). |
(mount) /etc/bastion/allowed-commands.list |
(unset) | Broker mode, optional: same format as ALLOWED_COMMANDS, re-read every request (live edits, no restart). Additive. |
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). - bastion-broker — the allowlist gate for broker mode: whole-line ERE match, then shell-free word-split
exec. Shared by the SSH and HTTP paths. - busybox httpd (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when
HTTP_TOKENorHTTP_BASIC_AUTHis 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. In FORCE_COMMAND modeSSH_ORIGINAL_COMMANDis dropped entirely. In broker mode it is read but only ever run if it matches the allowlist whole-line, and even then via shell-free word-splitexec(nosh -c), so the regex is a containment boundary, not just a filter — see Command broker mode. 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
├─ pick mode: broker if $ALLOWED_COMMANDS / allowed-commands.list, else force-command
│ ├─ broker: snapshot allowlist + prefix; wrapper → bastion-broker
│ └─ force-command: 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_BASIC_AUTH or $HTTP_TOKEN is set)
└─ exec sshd -D -e
FORCE_COMMAND mode broker mode
------------------ -----------
ssh client ssh client
└ key auth as `agent` └ key auth as `agent`
└ ForceCommand wrapper └ ForceCommand wrapper
└ exec sh -c "$FORCE_COMMAND" └ exec bastion-broker "$SSH_ORIGINAL_COMMAND"
├ match vs allowlist (whole-line, anchored)
http client └ exec $COMMAND_PREFIX $request (no shell)
└ Authorization header
└ /cgi-bin/run validates auth http client
└ exec force-command wrapper └ Authorization header
└ exec sh -c "$FORCE_COMMAND" └ /cgi-bin/run validates auth
└ exec bastion-broker "$X-Bastion-Command"
License
MIT.