Files
certctl/deploy/docker-compose.yml
T
shankar0123 7e2481b225 fix(deploy): SEC-014 — loopback-bind Postgres host port in compose files
Acquisition-audit SEC-014 closure (Sprint 2 ACQ, 2026-05-16).

Both deploy/docker-compose.yml and deploy/docker-compose.test.yml
published Postgres on `5432:5432` — the short Docker port-mapping
form, which binds to 0.0.0.0 by default. On any host with a
public-facing NIC, that quietly exposed the Postgres TCP listener to
the internet. The certctl-server-to-postgres traffic itself goes over
the `certctl-network` Docker bridge, not the host port; the host
port mapping is a convenience for operator psql access and for the
integration-test runner that lives on the host.

Switch both mappings to `127.0.0.1:5432:5432` (loopback-only).
Operator psql via `localhost` keeps working; the integration-test
runner keeps working; cross-host exposure goes away.

Audit trail: docs/operator/security.md (Postgres transport encryption
subsection, SEC-014 paragraph).
2026-05-16 17:12:42 +00:00

334 lines
15 KiB
YAML

# =============================================================================
# certctl base compose — PRODUCTION-SHAPED (Bundle 2, 2026-05-12)
# =============================================================================
#
# This base file ships a SAFE-BY-DEFAULT control plane:
#
# - CERTCTL_AUTH_TYPE defaults to api-key (the code default; not overridden
# here). The server REFUSES to start with auth=none on a non-loopback
# bind unless CERTCTL_DEMO_MODE_ACK=true (Audit 2026-05-10 HIGH-12 +
# Bundle 2 closure: see internal/config/config.go::Validate).
# - CERTCTL_KEYGEN_MODE defaults to agent (the code default).
# - CERTCTL_DEMO_SEED defaults to false (the code default; the 180-day
# simulated history seed only runs under the demo overlay).
# - Default placeholder credentials (`change-me-...` sentinels) are NOT
# interpolated by this compose. The server REFUSES to start when those
# placeholder strings reach config (Bundle 2 fail-closed guards) unless
# DEMO_MODE_ACK=true. Operators MUST set:
# POSTGRES_PASSWORD (openssl rand -hex 32)
# CERTCTL_AUTH_SECRET (openssl rand -hex 32)
# CERTCTL_CONFIG_ENCRYPTION_KEY (openssl rand -base64 32)
# CERTCTL_API_KEY (matches CERTCTL_AUTH_SECRET or one
# of its rotation siblings)
# CERTCTL_AGENT_ID (returned from POST /api/v1/agents)
# in deploy/.env or the shell environment. See deploy/.env.example.
#
# USAGE
# -----
#
# Production-shaped (this base alone):
# docker compose -f deploy/docker-compose.yml up -d
#
# Bundled demo (zero-config, populated dashboard, demo-mode auth):
# docker compose -f deploy/docker-compose.yml \
# -f deploy/docker-compose.demo.yml up -d
#
# The demo overlay (docker-compose.demo.yml) layers in the demo-mode env
# vars (AUTH_TYPE=none + DEMO_MODE_ACK=true + KEYGEN_MODE=server +
# DEMO_SEED=true + the change-me placeholder creds). It exists so the
# `docker compose up` smoke + screenshot path stays one command — but it
# ALSO carries the operator-visible warning banner the server emits at
# boot when DEMO_MODE_ACK=true.
#
# Pre-Bundle-2 this base file WAS the demo path. The split happened in
# 2026-05-12; the README quickstart, deploy/ENVIRONMENTS.md, and the
# cold-DB compose smoke in .github/workflows/ci.yml were updated in the
# same commit to point at the new layout.
services:
# HTTPS-Everywhere Phase 3 — self-signed TLS bootstrap (init container).
# Generates a CN=certctl-server ECDSA-P256 (SHA-256 signature) cert with
# the SAN list locked by milestone §3.6 on first boot; subsequent boots
# see the cert already present in the `certs` named volume and no-op out.
# Server + agent mount the volume read-only. Destroy via `docker compose
# down -v` to force regeneration. This bootstrap is for docker-compose
# demos and local dev only; Helm operators supply a Secret / cert-manager
# Certificate per docs/tls.md.
#
# Rationale for ECDSA-P256 (was ed25519 pre-v2.0.48): Apple's TLS stack
# — Safari Network Framework and the macOS-bundled LibreSSL 3.3.6
# /usr/bin/curl — does not advertise ed25519 in the ClientHello
# signature_algorithms extension for server certs, yielding "tls: peer
# doesn't support any of the certificate's signature algorithms" at
# handshake. ECDSA-P256 with SHA-256 is universally supported. See
# docs/tls.md Pattern 1.
certctl-tls-init:
# DEPL-002 closure (Sprint 3, 2026-05-16): digest-pin so the
# production-shaped compose has the same supply-chain posture as
# the certctl Dockerfiles (which CI guards via digest-validity.sh).
# The :latest tag floats; the digest is captured at the time
# this comment was written. Bump after running the digest-
# validity guard to confirm the new digest is still pullable.
image: alpine/openssl:latest@sha256:41036db23542ed4cc09bc278d8a7e23b3da01690abb4b0e353b1bb87d70520ed
container_name: certctl-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/etc/certctl/tls/server.crt
KEY=/etc/certctl/tls/server.key
CA=/etc/certctl/tls/ca.crt
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
echo "TLS cert already present at $$CERT — skipping generation"
else
mkdir -p /etc/certctl/tls
openssl req -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 \
-nodes \
-keyout "$$KEY" \
-out "$$CERT" \
-days 3650 \
-subj "/CN=certctl-server" \
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
cp "$$CERT" "$$CA"
echo "Generated self-signed TLS cert for certctl-server (ECDSA-P256/SHA-256, 3650d, CN=certctl-server)"
fi
# certctl binary runs as UID 1000 inside the server container per
# Dockerfile:64-65; the cert + key must be readable by that UID.
chown 1000:1000 "$$CERT" "$$KEY" "$$CA"
chmod 0644 "$$CERT" "$$CA"
chmod 0600 "$$KEY"
volumes:
- certs:/etc/certctl/tls
networks:
- certctl-network
# PostgreSQL database
#
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10):
# Pre-U-3 this stack mounted a hand-curated subset of `migrations/*.up.sql`
# plus `seed.sql` into `/docker-entrypoint-initdb.d/`, and postgres
# initdb-applied them on first boot. The mount list rotted every time a
# new migration shipped that the seed depended on (000013 added
# policy_rules.severity, 000017 renames retry_interval_minutes, etc.) —
# initdb crashed, the container reported `unhealthy` indefinitely, and
# `docker compose -f deploy/docker-compose.yml up -d --build` from a
# fresh clone of v2.0.50 hit it on the first try.
#
# Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
# internal/repository/postgres.RunMigrations + RunSeed. Single source of
# truth, no list to keep in sync. Postgres comes up empty; the server
# waits for it healthy, then applies the full migration ladder + seed in
# one shot. Helm + the dev examples were already runtime-only (Path B)
# and worked through the same window.
#
# `start_period: 30s` gives postgres room to bootstrap on slow runners
# (CI macOS, low-spec laptops) before the healthcheck failure counter
# starts ticking. Pre-U-3 a slow first-init combined with the
# `unhealthy` flap to cascade into certctl-server's `service_healthy`
# depends_on, blocking the whole stack.
postgres:
# DEPL-002 closure (Sprint 3, 2026-05-16): digest-pin matching the
# alpine/openssl pin above. The `16-alpine` tag is the stable
# major-version stream; the digest snapshots today's image so a
# silent upstream rebuild can't slip into a production deploy
# mid-rollout. Bump alongside dependency reviews.
image: postgres:16-alpine@sha256:890480b08124ce7f79960a9bb16fe39729aa302bd384bfd7c408fee6c8f7adb7
container_name: certctl-postgres
environment:
POSTGRES_DB: certctl
POSTGRES_USER: certctl
# Bundle 2 closure: no `:-certctl` fallback. Operators MUST set
# POSTGRES_PASSWORD in deploy/.env or the shell environment. The
# demo overlay (docker-compose.demo.yml) supplies a fixed weak
# default for screenshot/demo use; production deploys never
# depend on that fallback.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Acquisition-audit SEC-014 closure (Sprint 2, 2026-05-16). Bind
# the published port to 127.0.0.1 ONLY — the certctl-server
# connection comes in via the `certctl-network` Docker network
# (the host-port mapping is operator convenience for psql / DB
# inspection only). Pre-fix, the "5432:5432" form bound on
# 0.0.0.0, exposing the Postgres TCP listener on every interface
# of any host that happened to be on a public IP. The loopback
# bind keeps host-side psql access working while preventing the
# cross-network exposure landmine for compose deploys that aren't
# behind a firewall.
ports:
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- certctl-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U certctl -d certctl"]
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Certctl Server (API + scheduler)
certctl-server:
build:
context: ..
dockerfile: Dockerfile
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
# vars into the Docker build so the Node frontend stage and Go module
# download can reach the public registries behind corporate proxies.
# Defaults to empty; omit the variables from the host environment for
# un-proxied builds and the behaviour is byte-identical to the pre-fix
# tree.
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-}
container_name: certctl-server
depends_on:
postgres:
condition: service_healthy
certctl-tls-init:
condition: service_completed_successfully
environment:
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): in-cluster Postgres
# on the docker bridge network keeps sslmode=disable acceptable; for
# external/managed Postgres operators MUST override CERTCTL_DATABASE_URL
# with sslmode=verify-full and provide the CA bundle. See docs/database-tls.md.
CERTCTL_DATABASE_URL: ${CERTCTL_DATABASE_URL:-postgres://certctl:${POSTGRES_PASSWORD}@postgres:5432/certctl?sslmode=disable}
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: info
# Bundle 2 closure (compose split). The base compose no longer
# sets CERTCTL_AUTH_TYPE / CERTCTL_KEYGEN_MODE / DEMO_MODE_ACK /
# DEMO_SEED — the code defaults take over (auth-type api-key,
# keygen agent, demo-mode false, demo-seed false). The demo
# overlay (docker-compose.demo.yml) is what flips this baseline
# into the populated-dashboard demo path; without that overlay
# the server boots production-shaped and refuses to start unless
# the operator has supplied CERTCTL_AUTH_SECRET +
# CERTCTL_CONFIG_ENCRYPTION_KEY.
#
# Audit 2026-05-10 HIGH-12: when DEMO_MODE_ACK=true (set by the
# demo overlay) AND the listener binds to a non-loopback address,
# every request is served as the synthetic admin actor
# `actor-demo-anon`. The server emits a prominent boot-time WARN
# banner with a production-promotion checklist in that case.
CERTCTL_AUTH_SECRET: ${CERTCTL_AUTH_SECRET}
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY} # AES-256-GCM for dynamic issuer/target config
# Bootstrap token interpolation surface (Auditable Codebase Bundle
# cold-DB smoke closure, 2026-05-12). Pre-fix, the `env-file +
# --force-recreate certctl-server` pattern documented in
# cowork/manual-testing-bundle-2.html (and used by the cold-DB
# smoke job in .github/workflows/ci.yml::cold-db-compose-smoke)
# set CERTCTL_BOOTSTRAP_TOKEN in compose's own interpolation
# environment but the container never received it because this
# block didn't reference the variable. Wiring it as an explicit
# interpolation (default empty) makes the documented manual flow
# actually work end-to-end. Empty value = bootstrap strategy
# disabled (server returns 410 Gone on POST /api/v1/auth/bootstrap),
# which is the safe default — only set the var when you intend to
# mint a day-0 admin via the bootstrap path.
CERTCTL_BOOTSTRAP_TOKEN: ${CERTCTL_BOOTSTRAP_TOKEN:-}
ports:
- "8443:8443"
volumes:
- certs:/etc/certctl/tls:ro
networks:
- certctl-network
healthcheck:
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "https://localhost:8443/health"]
interval: 10s
timeout: 5s
retries: 5
# U-3: server boot now does RunMigrations + RunSeed before listening on
# 8443. On a fresh clone the full migration ladder + seed application
# can take ~10s on a small VM; start_period prevents the first few
# healthcheck attempts from counting as failures while that work runs.
start_period: 30s
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
# Certctl Agent
certctl-agent:
build:
context: ..
dockerfile: Dockerfile.agent
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
# vars into the Docker build so the Go module download stage can reach
# the public Go module proxy behind corporate proxies. Defaults to
# empty; omit the variables from the host environment for un-proxied
# builds and the behaviour is byte-identical to the pre-fix tree.
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-}
container_name: certctl-agent
depends_on:
certctl-server:
condition: service_healthy
environment:
CERTCTL_SERVER_URL: https://certctl-server:8443
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
# Bundle 2 closure (compose split). No placeholder fallbacks.
# Operators MUST set CERTCTL_API_KEY (matching one of the server's
# CERTCTL_AUTH_SECRET rotation values) and CERTCTL_AGENT_ID
# (returned from `POST /api/v1/agents` during agent enrollment).
# Without an agent ID, cmd/agent/main.go fails fast at startup
# with "agent-id flag or CERTCTL_AGENT_ID env var is required" —
# the cold-DB compose smoke in .github/workflows/ci.yml tolerates
# the agent restart loop because the smoke targets server boot
# only. The demo overlay (docker-compose.demo.yml) supplies a
# pre-seeded agent-demo-1 row + matching env vars so the demo
# path stays one-command.
CERTCTL_API_KEY: ${CERTCTL_API_KEY}
CERTCTL_AGENT_ID: ${CERTCTL_AGENT_ID}
CERTCTL_AGENT_NAME: docker-agent
CERTCTL_LOG_LEVEL: info
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
volumes:
- agent_keys:/var/lib/certctl/keys
- certs:/etc/certctl/tls:ro
networks:
- certctl-network
healthcheck:
test: ["CMD-SHELL", "pgrep -f certctl-agent || exit 1"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
networks:
certctl-network:
driver: bridge
volumes:
postgres_data:
driver: local
agent_keys:
driver: local
certs:
driver: local