# Deployment The server ships as a minimal static image and runs as interchangeable replicas in front of external Postgres and Redis. This guide covers the image, the cluster contract, graceful shutdown, the mTLS network constraint, and the supply-chain gates. ## Purpose For an operator deploying the server: what the image contains, what the runtime expects, and which constraints are enforced rather than advisory. ## The container image The image is built by the four-stage `Dockerfile` in the repository root: 1. `chef`: a pinned Rust base plus `cargo-chef` and the two musl targets. 2. `planner`: captures the dependency recipe so dependency layers cache. 3. `builder`: cooks dependencies, then builds the locked, stripped, static musl binary. `SQLX_OFFLINE=true` builds against the committed `.sqlx/` cache, so the build needs no database. 4. `runtime`: a pinned-digest distroless `static-debian12:nonroot` base. It has no shell and no package manager. Only the stripped binary is copied in. Properties the image guarantees: - Static musl binary (`ldd` reports not a dynamic executable). The rustls `ring` provider is used, with no OpenSSL and no `aws-lc-sys` in the graph. - Non-root `USER 65532:65532`. - Exec-form `ENTRYPOINT ["/d-oauth-server", "serve"]`, so no shell interprets it. - `EXPOSE 8080` for the TLS public listener. The plaintext health listener (default `0.0.0.0:8081`) is intentionally not published; it is probed inside the pod network only. - `HEALTHCHECK` runs `["/d-oauth-server", "healthcheck"]`, which speaks raw HTTP/1.1 over loopback to the health listener. This works without a shell or curl. - Reproducible: `SOURCE_DATE_EPOCH` is set, and the base images are pinned by digest. Build with `docker buildx bake`, which reads `docker-bake.hcl`. The `image` target builds both `linux/amd64` and `linux/arm64` by default. To build a single architecture, override the platform: ```bash # Both architectures (linux/amd64 + linux/arm64) docker buildx bake image # Single architecture docker buildx bake image --set image.platform=linux/amd64 ``` Building `linux/arm64` on an amd64 host requires QEMU binfmt emulation. Docker Desktop bundles it; a bare Docker Engine does not. When it is missing, the arm64 leg fails fast with `exec /bin/sh: exec format error`, because BuildKit pulls the real arm64 base image but the kernel cannot execute its arm64 binaries. Register the handler once: ```bash docker run --privileged --rm tonistiigi/binfmt --install arm64 ``` The registration lives in `binfmt_misc` and does not survive a reboot; make it durable with a `--restart=always` binfmt container or the distro's `qemu-user-static` and `binfmt-support` packages. A single-architecture build, such as `--set image.platform=linux/amd64`, needs no emulation and is the fast local option. The full arm64 build runs every compile under emulation, so it is noticeably slower than the amd64 build. Then run the image: ```bash docker run --rm -p 8080:8080 --read-only d-oauth-server:latest ``` The container needs its configuration from the environment, its TLS material mounted read-only, and reachable Postgres and Redis. It holds no state itself, so a read-only root filesystem is expected. ## TLS-terminating reverse proxy (HAProxy, Caddy) The server always terminates TLS on `bind_addr`; there is no plaintext mode. When a reverse proxy already holds the public certificate and terminates client TLS (L7), point the proxy's HTTPS upstream at the server's TLS listener. The server keeps its own certificate for the proxy-to-app hop, but that certificate can be private, because only the proxy sees it. Keep verification on: rather than disabling it, trust a private CA so the proxy verifies both the backend chain and its hostname. This L7 model cannot carry a client certificate, so it does not support in-process client-certificate mTLS. For that, use L4/TCP passthrough (see the mTLS section below). Everything else, including DPoP, PAR, and JAR, works unchanged. ### Generate a private CA and a server certificate Create a small internal CA, then issue the server a `serverAuth` leaf whose SAN matches the name the proxy verifies. Save this extension file next to the certificates as `d-oauth.ext`: ```text basicConstraints=critical,CA:FALSE keyUsage=critical,digitalSignature,keyEncipherment extendedKeyUsage=serverAuth subjectAltName=DNS:d-oauth-backend,DNS:d-oauth.internal ``` ```bash # 1. Private internal CA. Keep internal-ca.key offline or in a secret manager. openssl genrsa -out internal-ca.key 4096 openssl req -x509 -new -key internal-ca.key -sha256 -days 3650 \ -subj "/CN=d-oauth internal CA" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ -out internal-ca.crt # 2. Server key and CSR. The SAN below, not the CN, is what the proxy verifies. openssl genrsa -out d-oauth.key 2048 openssl req -new -key d-oauth.key -subj "/CN=d-oauth-backend" -out d-oauth.csr # 3. Sign the leaf with the internal CA. openssl x509 -req -in d-oauth.csr \ -CA internal-ca.crt -CAkey internal-ca.key -CAcreateserial \ -days 825 -sha256 -extfile d-oauth.ext -out d-oauth.crt chmod 0400 d-oauth.key ``` Mount `d-oauth.crt` and `d-oauth.key` read-only into the container at `/certs` and set: ```bash D_OAUTH_ISSUER=https://oauth.example.com D_OAUTH_TLS__CERT_PATH=/certs/tls.crt D_OAUTH_TLS__KEY_PATH=/certs/tls.key ``` Give the proxy `internal-ca.crt` so it can verify the backend. Confirm the leaf before wiring the proxy: ```bash openssl x509 -in d-oauth.crt -noout -ext subjectAltName -dates openssl s_client -connect d-oauth-backend:8080 \ -CAfile internal-ca.crt -verify_hostname d-oauth-backend :8081/health/live wget -qO- http://:8081/health/ready ``` - The container healthcheck, usable without a shell: ```bash d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live ``` - On a rolling upgrade, watch that the PDB keeps at least two replicas ready and that readiness recovers after the drain. ## File and directory references - `Dockerfile` for the four stages and runtime contract. - `docker-bake.hcl` for the multi-arch targets. - `.dockerignore` for the build context. - `deny.toml` for the supply-chain policy. - `RUNBOOK.md` for the admin bootstrap Job. - `.github/workflows/ci.yml` for the quality and documentation gates. - `.github/workflows/supply-chain.yml` for the image pipeline: multi-arch bake with SBOM + provenance, trivy/grype HIGH/CRITICAL gates, keyless cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the weekly rebuild. - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml` (non-secret settings only, including the passwordless DSNs), `secret.yaml` (placeholder secrets — pepper, TOTP key, database and Redis passwords — all file-mounted and replaced by the secret manager), `service.yaml` (public TLS port only, never the health port), `deployment.yaml` (3 stateless replicas, probes on the plaintext health listener, PDB + topology spread, `automountServiceAccountToken: false`, every secret file-mounted), `migrate-job.yaml` (exactly-once schema migration, never per replica), `networkpolicy.yaml` (default-deny egress except DNS + Postgres/Redis scoped to their namespace, no `0.0.0.0/0` peer; ingress to the two pod ports), `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml` (committed placeholder digest; the release pipeline pins the real image digest here), and `tls-secret.example.yaml` (how to provision `/certs`, never applied). No Ingress is shipped: mTLS requires L4/TCP passthrough. - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis, one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random host ports so `--scale app=3` never collides). - `scripts/check_manifests.py` for the manifest policy lint, including the L7-ingress-with-client-cert-auth rejection.