The repository had no container image and no production-shaped serving configuration. `dist/server.mjs` is a preview server that applies neither the security headers nor the cache policy `config/hosting/` declares, so a deployment had nothing correct to run. `scripts/generate-nginx-config.ts` derives the server block from `dist/tech-log-serving-contract.json` plus the two hosting policy files, so the served headers and cache lifetimes cannot drift from what the contract declares. It emits no TLS and no proxy blocks: the edge terminates TLS and routes /api, and baking a backend address into the image would tie the bundle to one deployment. Static surfaces use `alias` because a base-path build serves /dev/assets/... out of dist/assets/..., which `root` plus URI would look for one directory too deep. The image copies that config next to the bundle and normalises permissions: the build writes config.json 0600, which nginx cannot read, so the container came up healthy and answered 403 for the one file the SPA needs to boot. index.html never referenced public/favicon.svg. The file shipped and nginx served it, but browsers asked for /favicon.ico, got a 404, and fell back to the default icon. `%BASE_URL%` rather than an absolute path so a prefixed deployment points at its own copy. development.json moves to the HTTP Studio source; the mock source has no backend to authenticate against, which is the whole point of that profile.
181 lines
7.7 KiB
YAML
181 lines
7.7 KiB
YAML
# The Tech Log dev stack: one origin, five services.
|
|
#
|
|
# nginx is the only published port. Everything the browser touches — the SPA,
|
|
# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same
|
|
# origin, which is what lets the session be a plain first-party httpOnly cookie
|
|
# instead of a cross-site one needing SameSite=None.
|
|
#
|
|
# browser ──> frontend(nginx) ──┬─> / SPA bundle
|
|
# ├─> /api backend
|
|
# ├─> /oauth2 /login /logout backend (BFF)
|
|
# └─> /auth keycloak
|
|
#
|
|
# Secrets here are development values and are meant to be replaced by the
|
|
# deployment; they are named in .env so nothing is baked into an image.
|
|
|
|
name: tech-log
|
|
|
|
services:
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
environment:
|
|
POSTGRES_DB: ${POSTGRES_DB:-tech_log}
|
|
POSTGRES_USER: ${POSTGRES_USER:-tech_log}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
TZ: UTC
|
|
volumes:
|
|
- postgres-data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 20s
|
|
restart: unless-stopped
|
|
networks: [tech-log]
|
|
|
|
redis:
|
|
# Holds the Studio session. Losing it signs everyone out; it holds nothing
|
|
# else, so it is not backed by a volume on purpose.
|
|
image: redis:7-alpine
|
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 5
|
|
restart: unless-stopped
|
|
networks: [tech-log]
|
|
|
|
keycloak:
|
|
image: quay.io/keycloak/keycloak:26.7.0
|
|
command: ["start-dev", "--import-realm", "--http-relative-path=/auth"]
|
|
environment:
|
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
|
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
|
|
KC_HTTP_ENABLED: "true"
|
|
# Behind nginx: Keycloak must build its URLs from the forwarded host, or
|
|
# the redirect back from the login page points at the container.
|
|
KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth
|
|
KC_HOSTNAME_STRICT: "false"
|
|
KC_PROXY_HEADERS: xforwarded
|
|
KC_HEALTH_ENABLED: "true"
|
|
volumes:
|
|
- ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 20
|
|
start_period: 40s
|
|
restart: unless-stopped
|
|
networks: [tech-log]
|
|
|
|
backend:
|
|
image: ${BACKEND_IMAGE:-tech-log-backend:local}
|
|
volumes:
|
|
- tls-public:/tls-public:ro
|
|
# The image entrypoint is `java -jar /app/app.jar`; this wraps it so the
|
|
# frontend's certificate lands in the JVM truststore first. Without it the
|
|
# OIDC metadata fetch fails PKIX validation and the process crash-loops.
|
|
entrypoint:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
until [ -f /tls-public/server.crt ]; do sleep 1; done
|
|
# The image runs as a non-root user, so the JVM's own cacerts is not
|
|
# writable — importing there silently did nothing and the metadata fetch
|
|
# kept failing PKIX. Copy it somewhere writable, add the edge
|
|
# certificate, and point the JVM at that.
|
|
cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks
|
|
keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \
|
|
-file /tls-public/server.crt \
|
|
-keystore /tmp/truststore.jks -storepass changeit
|
|
exec java \
|
|
-Djavax.net.ssl.trustStore=/tmp/truststore.jks \
|
|
-Djavax.net.ssl.trustStorePassword=changeit \
|
|
-jar /app/app.jar
|
|
environment:
|
|
SPRING_PROFILES_ACTIVE: local
|
|
# Persistence
|
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log}
|
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log}
|
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
|
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
|
|
SPRING_FLYWAY_ENABLED: "true"
|
|
SPRING_JPA_HIBERNATE_DDL_AUTO: none
|
|
CA_SKELETON_PERSISTENCE_VENDOR: postgresql
|
|
# BFF session
|
|
CA_SKELETON_SECURITY_AUTH_MODE: redis-session
|
|
CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION
|
|
APP_REDIS_ENABLED: "true"
|
|
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true"
|
|
SPRING_DATA_REDIS_HOST: redis
|
|
SPRING_DATA_REDIS_PORT: "6379"
|
|
# OIDC. The issuer is the browser-facing URL because the tokens carry it
|
|
# and the browser is redirected there; the container reaches the same
|
|
# Keycloak through nginx on the compose network.
|
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff}
|
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email
|
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code
|
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak"
|
|
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log}
|
|
APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author}
|
|
APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio"
|
|
# Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs
|
|
# and client IPs are the browser's, not the container's.
|
|
APP_SERVER_FORWARD_HEADERS_STRATEGY: framework
|
|
TZ: UTC
|
|
# The issuer in a token is the browser-facing URL, and the backend has to
|
|
# both validate that exact string and fetch the realm's metadata from it.
|
|
# Inside the container that host does not resolve, so discovery failed and
|
|
# the process crash-looped. Mapping the public host to the docker gateway
|
|
# makes one URL work from both sides — the browser reaches nginx directly,
|
|
# the backend reaches the same nginx through the published port.
|
|
extra_hosts:
|
|
- "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway"
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
keycloak: { condition: service_healthy }
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 45s
|
|
restart: unless-stopped
|
|
networks: [tech-log]
|
|
|
|
frontend:
|
|
image: ${FRONTEND_IMAGE:-tech-log-frontend:local}
|
|
# The backend fetches the realm metadata from the same HTTPS origin the
|
|
# browser uses, so it has to trust this certificate. Publishing it to a
|
|
# shared volume keeps one certificate for both sides; a deployment that
|
|
# mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor
|
|
# the backend's import step.
|
|
volumes:
|
|
- tls-public:/tls-public
|
|
command:
|
|
- /bin/sh
|
|
- -c
|
|
- "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'"
|
|
|
|
ports:
|
|
- "${PUBLIC_HTTP_PORT:-8088}:80"
|
|
- "${PUBLIC_PORT:-8443}:443"
|
|
depends_on:
|
|
backend: { condition: service_started }
|
|
keycloak: { condition: service_started }
|
|
restart: unless-stopped
|
|
networks: [tech-log]
|
|
|
|
networks:
|
|
tech-log:
|
|
driver: bridge
|
|
|
|
volumes:
|
|
postgres-data:
|
|
tls-public:
|