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`.
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.
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.
```yaml
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).
`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.
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).
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 with a clear error.
- **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, `/sbin/nologin` login shell, global `ForceCommand` directive.
- **busybox httpd** (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when `HTTP_TOKEN` is set.
- **docker-cli + docker-cli-compose** — so `FORCE_COMMAND` can 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.
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.
1.**Key-only SSH, no passwords** — enforced in `sshd_config`.
2.**Token-only HTTP** — no path is open without `Authorization: Bearer`.
3.**No agent / TCP / X11 forwarding, no port tunnels** — enforced in `sshd_config`.
4.**Login shell is `/sbin/nologin`** — no fallback if `ForceCommand` somehow misfires.
5.**`PermitUserEnvironment no`, `PermitUserRC no`** — clients cannot inject env vars or rc files.
6.**Bind host ports to `127.0.0.1` or hide them behind traefik+TLS unless you genuinely need them publicly open on raw TCP.** The traefik path with `entrypoints: websecure` and `tls: true` is the recommended public exposure.
7.**Rotate `HTTP_TOKEN` regularly.** Generate with `openssl rand -hex 32`, store in `.env`, never commit.
8.**Keep alpine + openssh patched.** An unauth RCE in sshd or httpd here means host root. `apk upgrade` in a rebuild cycle.
9.**Lock down siblings.** Anyone who can `docker exec` into the app via this bastion can also `docker exec` into `mysql`/`redis`/etc through the same socket. `cap_drop: [ALL]` and `no-new-privileges: true` on every sibling caps the blast radius.
10.**One bastion per role.** Don't reuse a single `FORCE_COMMAND` for both interactive shells and deploy automation — separate ports and separate token/key sets make audit trails meaningful.