# 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 ``` To emit several tags from one build, set `IMAGE_TAGS` to a comma-separated list. A `--set image.tags=` value is parsed as a single string, so a comma list there becomes one invalid tag rather than a tag set: ```bash IMAGE_TAGS="ghcr.io/syavne/d-oauth-server:v1.2.3,ghcr.io/syavne/d-oauth-server:latest" \ docker buildx bake image ``` The CI release path does not use `IMAGE_TAGS` for its tag set: each build leg sets it to the image name and pushes that architecture by digest, and the `merge` job applies the release tags with `docker buildx imagetools create`; see [Supply chain](#supply-chain). 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. The server pins a TLS 1.2 minimum (exactly TLS 1.2 + TLS 1.3, per draft-ietf-oauth-v2-1-16 §1.5 and §7.14), so clients and proxies must offer 1.2 or above. 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 `, ``, ``, and `latest`, and creates a GitHub Release with generated notes (a pre-release such as `v1.2.3-rc1` gets only ``). No `sha-` tags are published. These tags are convenience references; the deploy path applies the mutable `latest` tag (see [Deploy path](#deploy-path-use-the-ghcrio-image)). - Registry cleanup (job `cleanup`, after every push plus weekly + manual): GHCR has no retention and keeps superseded manifests as untagged versions, so the job deletes untagged manifests and the legacy `sha-*` tags, keeping `latest` and every release tag. - A vulnerability scan that fails on HIGH and CRITICAL (job `merge`, steps `Scan (trivy, gate HIGH/CRITICAL)` writing `trivy.sarif` and `Scan (grype, gate high/critical)`), plus step `Upload trivy SARIF to code scanning` publishing `trivy.sarif` to the repository's code-scanning (Security) tab with category `trivy`; the SBOM is committed as the `sbom.spdx.json` artifact by step `SBOM (syft, committed as artifact)`. - Keyless signing with `cosign` v3 (job `merge`, step `Keyless sign (cosign, OIDC)`: `cosign sign` on the merged index digest, stored as an OCI 1.1 referrer/bundle; the per-arch SBOM and SLSA provenance attestations are attached by the `build` legs). - `cargo deny check` in CI, which enforces the ring-only TLS policy in `deny.toml`. - A per-PR fuzz build plus a scheduled bounded run of every fuzz target (`.github/workflows/ci.yml`); a crash uploads its artifact and fails the run. ### Deploy path: use the `ghcr.io` image The only shippable artifact is the signed registry image, never a locally built tarball. Every rollout verifies provenance before applying anything: ```bash # 1. Verify the keyless cosign signature from the `merge` job's # `Keyless sign (cosign, OIDC)` step before pulling or applying. # Match the identity to the signing workflow. The tag resolves to the # current digest at verify time. cosign verify \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp 'supply-chain' \ 'ghcr.io/syavne/d-oauth-server:latest' # 2. Confirm the manifests resolve and pass policy. kustomize build deploy/k8s python3 scripts/check_manifests.py # 3. Roll out (migration Job first; the Deployment readiness gate waits for it). kubectl apply --dry-run=server -k deploy/k8s kubectl apply -k deploy/k8s ``` Signatures and attestations are written with cosign v3 as OCI 1.1 referrers and Sigstore bundles, not the legacy `sha256-.sig` tag; verify with a cosign v3+ client (cosign v2.6+ can read them with `--new-bundle-format`). The `cosign verify` flags above are unchanged from v2. The manifests apply `ghcr.io/syavne/d-oauth-server:latest`, a mutable tag. A rollout picks up whatever digest the tag points at when the node pulls it, so two nodes can briefly run different builds mid-rollout, and a later re-apply can pull a newer image than the one you verified. That is the deliberate tradeoff for a simple, low-ceremony admin flow; treat the tag as a convenience reference, not an immutable pin. Record the rollout in [RUNBOOK.md](../RUNBOOK.md) (deploy provenance entry: the digest the tag resolved to, the `sbom.spdx.json` / `trivy.sarif` run, and the `cosign verify` result). A rollout with no matching record is an unverified deploy: stop and re-verify. ### Fallback: local build (unsigned host, sign and verify locally) When the registry is unreachable, build locally with the same bake target the `build` job uses, sign with a local key, verify the signature, and only then load the image. Do not `docker save | docker load` an unsigned image onto a live host: ```bash # 1. Build the same target CI signs. docker buildx bake image --set image.platform=linux/amd64 # 2. Sign the local image and verify it before loading or running. # (Keyless OIDC is unavailable off CI, so a local key stands in; # keep the private key off the target host.) cosign sign --key 'd-oauth-server:latest' cosign verify --key 'd-oauth-server:latest' # 3. Load the verified image and record the deploy in RUNBOOK.md as an # explicitly local, non-`ghcr.io` rollout. ``` Prefer the signed `ghcr.io` path for every production rollout; the fallback exists so an air-gapped host still gets a sign/verify step, not an anonymous tarball. ## Security properties - No shell and no package manager in the runtime image narrows the attack surface to the binary. - Non-root with a read-only root filesystem. - Secrets and CA material are mounted, never baked into the image or manifests. - Every secret — token pepper, TOTP key, PII key, and the database and Redis passwords — is a `0400` tmpfs file, not an environment variable, and the pod carries no service-account token. - `kubectl exec` / `docker exec` reach a non-root process with a read-only rootfs, no capabilities, and no shell, but still bypass the application's own auth; restrict `pods/exec` with RBAC and audit it (see [OPERATIONS.md](OPERATIONS.md)). - A NetworkPolicy default-denies pod egress except DNS and the namespace hosting Postgres/Redis scoped to ports 5432/6379, forbids an internet-wide `0.0.0.0/0` peer, and admits ingress only on the two pod ports. - Health routes are unreachable from the public listener, so the probe port is not a second API surface. - Liveness never touches a dependency, so a dependency outage does not trigger a restart storm. ## Edge cases - A root or privileged pod violates the policy and must be rejected. - An L7-terminated ingress with client-cert auth cannot carry the cert; use L4/TCP passthrough. - Booting three replicas without the migration Job lets readiness block them until the schema matches. - A replica that has no shared state is a bug: token state is in Redis, not in the process. - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted; verify the cluster CNI applies the policy before relying on it. - Re-applying the manifests after a new push rolls forward: `:latest` is mutable, so an unchanged manifest can still pull a newer build than the one you verified. ## Operational runbook - Roll out a new image by re-applying the manifests; they track `ghcr.io/syavne/d-oauth-server:latest`, and the migration Job runs first. To roll a specific build instead, point the `images` entry at a release tag such as ``. - Probe commands inside the pod network: ```bash wget -qO- http://: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: per-arch native build (linux/amd64 on `ubuntu-24.04`, linux/arm64 on `ubuntu-24.04-arm`) with SBOM + provenance, a `merge` job that assembles the multi-arch manifest (`docker buildx imagetools create`) and runs the trivy/grype HIGH/CRITICAL gates (uploading the trivy SARIF to code scanning) plus keyless cosign signing, 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, PII 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` (an `images` entry pointing at `ghcr.io/syavne/d-oauth-server:latest`), 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.