Files
tech-log-frontend/docker-compose.dev-stack.yml
T
DongHyeonka 83409bef7a feat: give the frontend a deployment artifact, and show its logo
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.
2026-08-20 16:14:09 +09:00

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: