# 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: