1313# 5. Configure environment variables (see .env.example)
1414# 6. Configure domains in Coolify UI (General tab) on the MAIN application:
1515# - webapp: https://hephaestus.example.com
16- # - application-server : https://api.hephaestus.example.com
16+ # - appserver : https://api.hephaestus.example.com
1717#
1818# Preview deployments will AUTOMATICALLY get domains like:
1919# - webapp: https://{{pr_id}}.hephaestus.example.com
20- # - application-server : https://{{pr_id}}.api.hephaestus.example.com
20+ # - appserver : https://{{pr_id}}.api.hephaestus.example.com
2121#
2222# Coolify Auto-Generated Variables:
2323# - SERVICE_FQDN_WEBAPP: Domain for webapp (host only, no scheme)
2424# - SERVICE_URL_WEBAPP: Full URL for webapp (with https://)
25- # - SERVICE_FQDN_APPLICATION_SERVER : Domain for API (host only)
26- # - SERVICE_URL_APPLICATION_SERVER : Full URL for API (with https://)
25+ # - SERVICE_FQDN_APPSERVER : Domain for API (host only)
26+ # - SERVICE_URL_APPSERVER : Full URL for API (with https://)
2727# - SERVICE_NAME_<SERVICE>: Container name (e.g., postgres-wg44k0c-pr-557)
2828# - COOLIFY_CONTAINER_NAME: Unique container identifier
2929#
@@ -44,9 +44,13 @@ services:
4444 # SEEDING HACK: Uses init container to populate via psql commands
4545 # after postgres starts, rather than relying on /docker-entrypoint-initdb.d
4646 # ---------------------------------------------------------------------------
47+ # No `hostname:` override. Coolify attaches every stack to the shared `coolify`
48+ # network, where a fixed hostname would make each preview's postgres answer to the
49+ # same `postgres` name — an app-server could then reach another PR's database.
50+ # The compose service key is already a per-deploy-unique alias (`postgres` on the
51+ # base deploy, `postgres-pr-<id>` on previews); reference it via SERVICE_NAME_POSTGRES.
4752 postgres :
4853 image : ghcr.io/ls1intum/hephaestus/postgres:${IMAGE_TAG:-latest}
49- hostname : postgres
5054 restart : unless-stopped
5155 environment :
5256 POSTGRES_DB : hephaestus
@@ -81,14 +85,14 @@ services:
8185 environment :
8286 PGPASSWORD : ${POSTGRES_PASSWORD:-hephaestus-preview}
8387 SERVICE_NAME_POSTGRES : ${SERVICE_NAME_POSTGRES:-}
84- SERVICE_FQDN_APPLICATION_SERVER : ${SERVICE_FQDN_APPLICATION_SERVER :-}
88+ SERVICE_FQDN_APPSERVER : ${SERVICE_FQDN_APPSERVER :-}
8589 SERVICE_FQDN_WEBAPP : ${SERVICE_FQDN_WEBAPP:-}
8690 command : |
8791 sh -c '
8892 set -e
8993 apk add --no-cache docker-cli >/tmp/apk.log 2>&1
9094
91- APP_FQDN="${SERVICE_FQDN_APPLICATION_SERVER :-${SERVICE_FQDN_WEBAPP:-}}"
95+ APP_FQDN="${SERVICE_FQDN_APPSERVER :-${SERVICE_FQDN_WEBAPP:-}}"
9296 if ! echo "$$APP_FQDN" | grep -Eq "^pr[0-9]+"; then
9397 echo "Skipping seed-loader: non-PR deployment detected"
9498 exit 0
@@ -183,36 +187,104 @@ services:
183187 # labels from docker/compose.app.yaml are intentionally omitted here — they would be a
184188 # no-op. See docs/contributor/unified-pi-runtime.mdx.
185189 # ---------------------------------------------------------------------------
186- application-server :
187- build :
188- context : ./server
189- dockerfile : Dockerfile
190- hostname : application-server
190+ # Docker creates named volumes owned by root:root, but the buildpack image runs as the
191+ # unprivileged `cnb` user (1002:1001). Without this, GitRepositoryManager cannot mkdir under
192+ # /data/git-repos ("Failed to ensure repository" on every checkout) and Tomcat's AccessLogValve
193+ # cannot open /var/log/hephaestus/access. Previews get fresh root-owned volumes on every deploy,
194+ # so this cannot be a one-off fix applied by hand.
195+ volume-init :
196+ image : alpine:3
197+ command :
198+ - sh
199+ - -c
200+ - chown -R 1002:1001 /data/git-repos /var/log/hephaestus && chmod 0755 /data/git-repos /var/log/hephaestus
201+ volumes :
202+ - git-repos:/data/git-repos
203+ - appserver-logs:/var/log/hephaestus
204+ restart : " no"
205+
206+ # Never use `${VAR:?message}` in this file. Coolify's compose parser seeds its own environment
207+ # store from these placeholders and keeps the *message* as the value, so `:?Required` becomes the
208+ # literal string "Required" — which then fails the boot far from the cause (a base64 "Required" is
209+ # 6 bytes, not the 32 the state-cookie key needs). Leave required vars as a bare `${VAR}` and let
210+ # the app's own fail-fast report them.
211+ #
212+ # There is no server/Dockerfile: the image comes from Paketo buildpacks (spring-boot:build-image,
213+ # server/pom.xml), which also supplies the /workspace/health-check binary the healthcheck invokes.
214+ # ci-docker-build.yml publishes `latest` (main), `pr-<id>` (PRs), `<sha>`, `<branch>`. The webapp
215+ # below builds from source, so a preview always exercises the PR's frontend; set IMAGE_TAG=pr-<id>
216+ # to exercise the PR's server too (that tag exists only if the PR touched server/).
217+ #
218+ # The service name is `appserver`, not `application-server`, and it is load-bearing. Coolify stores
219+ # a service's configured domain under the service name but resolves it at deploy time with dashes
220+ # replaced by underscores; with a dashed name it did not find the entry and replaced the domain
221+ # with an auto-generated <service>-<uuid>.<wildcard> host on every deploy. A dash-free name makes
222+ # the two forms identical and the configured api.<domain> sticks.
223+ appserver :
224+ image : ghcr.io/ls1intum/hephaestus/application-server:${IMAGE_TAG:-latest}
191225 restart : unless-stopped
226+ expose :
227+ - " 8080"
192228 environment :
193229 SPRING_PROFILES_ACTIVE : prod
194230 # prod runs forward-headers=native, so ProxyTrustGuard aborts the boot unless this is set.
195231 # Coolify fronts previews with its own proxy — set its ingress regex, not staging's range.
196- HEPHAESTUS_TRUSTED_PROXIES : ${HEPHAESTUS_TRUSTED_PROXIES:?Required}
232+ #
233+ # Write this Tomcat internal-proxies regex WITHOUT backslashes. Coolify re-escapes them when
234+ # it writes the stack's .env, so "172\.18\." arrives in the container as "172\\.18\\." — which
235+ # as a Java regex matches a literal backslash and therefore never matches an IP. The valve then
236+ # silently distrusts Traefik, X-Forwarded-Proto is ignored, and every OAuth redirect_uri is
237+ # built as http:// (which the IdP rejects). An unescaped '.' matches any char, which is
238+ # harmless here: e.g. 172.(1[6-9]|2[0-9]|3[01]).[0-9]{1,3}.[0-9]{1,3}
239+ HEPHAESTUS_TRUSTED_PROXIES : ${HEPHAESTUS_TRUSTED_PROXIES}
197240 # Previews run non-release images with no release-pin, so don't require a digest-pinned agent image.
198241 HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST : ${HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST:-false}
199- # URL - Coolify provides SERVICE_FQDN_WEBAPP
242+ # The SPA's origin — NOT this server's. It feeds hephaestus.cors.allowed-origins,
243+ # hephaestus.webapp.url and the links the app hands to users.
200244 APPLICATION_HOST_URL : https://${SERVICE_FQDN_WEBAPP}
245+ # The API is served at the ROOT of its own host here (Coolify cannot route a compose service
246+ # on a path — it regenerates a host-only domain — so the SPA and the API get sibling hosts).
247+ # application-prod.yml pins api-base-path=/api for the production stack, where Traefik strips
248+ # that prefix. Nothing strips it here, so it must be cleared or every OAuth callback is built
249+ # as https://<api-host>/api/login/oauth2/code/... and 404s. An env var outranks the profile
250+ # YAML, and the callback's {baseUrl} comes from the request.
251+ HEPHAESTUS_AUTH_API_BASE_PATH : " "
201252 # Internal, trusted docker-network DB: skip TLS. pgJDBC doesn't validate Postgres' self-signed cert
202253 # so it adds no authentication, only the SSL handshake's NIO direct buffers that OOM the JVM off-heap
203254 # default; confidentiality is fine to drop here (creds already sit in plaintext compose env).
204- DATABASE_URL : postgresql://postgres:5432/hephaestus?sslmode=disable
255+ # SERVICE_NAME_POSTGRES is Coolify's per-deploy service key, so a preview can only ever
256+ # reach its own database (see the note on the postgres service).
257+ DATABASE_URL : postgresql://${SERVICE_NAME_POSTGRES:-postgres}:5432/hephaestus?sslmode=disable
205258 DATABASE_USERNAME : hephaestus
206259 DATABASE_PASSWORD : ${POSTGRES_PASSWORD:-hephaestus-preview}
207- # Shared infrastructure (must match shared-infra service names with UUID suffix)
208- # When "Connect to Predefined Network" is enabled, use the full container names
260+ # Shared infrastructure, reached over Coolify's predefined `coolify` network.
209261 NATS_SERVER : nats://nats-server:4222
262+ # hephaestus.sync.nats.server and hephaestus.agent.nats.server BOTH default to ${NATS_SERVER},
263+ # so they must be pinned separately — otherwise aiming the integration consumer at another
264+ # environment's NATS would also submit this stack's agent jobs into that environment's AGENT
265+ # queue, where its workers would execute them.
266+ #
267+ # The integration consumer may point at an upstream NATS (e.g. staging's) to consume webhook
268+ # events that a shared GitHub App delivers there, instead of receiving deliveries locally.
269+ # durable-consumer-name is per-deploy (below), so this stack cannot steal that environment's
270+ # messages — it gets its own JetStream consumers.
271+ HEPHAESTUS_SYNC_NATS_SERVER : ${HEPHAESTUS_SYNC_NATS_SERVER:-nats://nats-server:4222}
272+ # Agent job queue stays local, always.
273+ HEPHAESTUS_AGENT_NATS_SERVER : nats://nats-server:4222
210274 NATS_ENABLED : ${NATS_ENABLED:-true}
211- NATS_DURABLE_CONSUMER_NAME : ${COOLIFY_CONTAINER_NAME:-preview}-consumer
212- # Auth (Hephaestus-native; ADR 0017).
213- # Public URL for issuer validation (must match token's iss claim).
214- HEPHAESTUS_AUTH_ISSUER : https://${PREVIEW_DOMAIN:?Required}
215- HEPHAESTUS_AUTH_STATE_COOKIE_KEY : ${HEPHAESTUS_AUTH_STATE_COOKIE_KEY:?Required}
275+ # Per-deploy durable name: a shared one would make base and every preview compete for the
276+ # same JetStream consumer and steal each other's messages.
277+ NATS_DURABLE_CONSUMER_NAME : ${SERVICE_NAME_APPSERVER:-appserver}-consumer
278+ # Auth (Hephaestus-native; ADR 0017). Issuer is this deploy's own origin, so a preview's
279+ # tokens validate against the preview it was issued by.
280+ HEPHAESTUS_AUTH_ISSUER : https://${SERVICE_FQDN_WEBAPP}
281+ HEPHAESTUS_AUTH_STATE_COOKIE_KEY : ${HEPHAESTUS_AUTH_STATE_COOKIE_KEY}
282+ # First super-admin: the admin UI is admin-gated, so it must come from operator config.
283+ HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS : ${HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS:-}
284+ # AES-256-GCM key (exactly 32 chars) for credentials encrypted at rest in `connection`.
285+ # Required in prod by EncryptedStringConverter + JwtSigningKeySealer, and the Liquibase
286+ # backfill re-encrypts on boot — so it MUST be present wherever Liquibase runs.
287+ HEPHAESTUS_SECURITY_ENCRYPTION_KEY : ${HEPHAESTUS_SECURITY_ENCRYPTION_KEY}
216288 # Sourced from GH_OAUTH_* — GitHub Actions reserves the GITHUB_ prefix for secret/variable names.
217289 GITHUB_OAUTH_CLIENT_ID : ${GH_OAUTH_CLIENT_ID:-}
218290 GITHUB_OAUTH_CLIENT_SECRET : ${GH_OAUTH_CLIENT_SECRET:-}
@@ -221,10 +293,52 @@ services:
221293 GITLAB_OAUTH_BASE_URL : ${GITLAB_OAUTH_BASE_URL:-https://gitlab.com}
222294 GITLAB_OAUTH_DISPLAY_NAME : ${GITLAB_OAUTH_DISPLAY_NAME:-GitLab}
223295 # GitHub integration
224- GH_APP_ID : ${GH_APP_ID:?Required }
225- GH_APP_PRIVATE_KEY : ${GH_APP_PRIVATE_KEY:?Required }
296+ GH_APP_ID : ${GH_APP_ID}
297+ GH_APP_PRIVATE_KEY : ${GH_APP_PRIVATE_KEY}
226298 GH_APP_INSTALLATION_URL : ${GH_APP_INSTALLATION_URL:-}
227- GH_AUTH_TOKEN : ${GH_AUTH_TOKEN:?Required}
299+ GH_AUTH_TOKEN : ${GH_AUTH_TOKEN}
300+ # GitLab integration
301+ GITLAB_ENABLED : ${GITLAB_ENABLED:-false}
302+ GITLAB_DEFAULT_SERVER_URL : ${GITLAB_DEFAULT_SERVER_URL:-https://gitlab.lrz.de}
303+ HEPHAESTUS_FEATURES_FLAGS_GITLAB_WORKSPACE_CREATION : ${GITLAB_WORKSPACE_CREATION:-false}
304+ # Slack. OAuth/admin and mentor replies run here; inbound Events API + interactivity are
305+ # verified on webhook-server (shared infra), which needs the same signing secret.
306+ HEPHAESTUS_INTEGRATION_SLACK_ENABLED : ${HEPHAESTUS_INTEGRATION_SLACK_ENABLED:-false}
307+ HEPHAESTUS_INTEGRATION_SLACK_CLIENT_ID : ${HEPHAESTUS_INTEGRATION_SLACK_CLIENT_ID:-}
308+ HEPHAESTUS_INTEGRATION_SLACK_CLIENT_SECRET : ${HEPHAESTUS_INTEGRATION_SLACK_CLIENT_SECRET:-}
309+ HEPHAESTUS_INTEGRATION_SLACK_SIGNING_SECRET : ${HEPHAESTUS_INTEGRATION_SLACK_SIGNING_SECRET:-}
310+ HEPHAESTUS_INTEGRATION_SLACK_REDIRECT_URI : ${HEPHAESTUS_INTEGRATION_SLACK_REDIRECT_URI:-}
311+ # Practice review + Pi mentor. Execution happens on application-worker; these gate it.
312+ PRACTICE_REVIEW_FOR_ALL : ${PRACTICE_REVIEW_FOR_ALL:-false}
313+ HEPHAESTUS_FEATURES_FLAGS_PRACTICE_REVIEW_FOR_ALL : ${PRACTICE_REVIEW_FOR_ALL:-false}
314+ AGENT_NATS_ENABLED : ${AGENT_NATS_ENABLED:-false}
315+ GIT_CHECKOUT_ENABLED : ${GIT_CHECKOUT_ENABLED:-false}
316+ GIT_STORAGE_PATH : /data/git-repos
317+ # Agent sandbox. There is no separate application-worker container here on purpose:
318+ # hephaestus.runtime.worker.enabled defaults to true, so this pod runs the worker role
319+ # in-process and executes practice reviews / Pi mentor turns itself. That only works if it
320+ # can reach the host Docker daemon — hence the docker.sock mount below (prod does the same).
321+ SANDBOX_DOCKER_HOST : ${SANDBOX_DOCKER_HOST:-unix:///var/run/docker.sock}
322+ # Previews share one host — keep the sandbox footprint small.
323+ SANDBOX_MAX_CONCURRENT : ${SANDBOX_MAX_CONCURRENT:-2}
324+ SANDBOX_MEMORY_BYTES : ${SANDBOX_MEMORY_BYTES:-4294967296}
325+ SANDBOX_CPUS : ${SANDBOX_CPUS:-2.0}
326+ # LLM proxy (OpenAI-compatible route). This is a verbatim path passthrough — the sandbox's
327+ # request path is appended to the base as-is, so give it the API ROOT and let the client add
328+ # /v1/chat/completions (same convention as the https://api.openai.com default).
329+ LLM_PROXY_OPENAI_URL : ${LLM_PROXY_OPENAI_URL:-https://api.openai.com}
330+ LLM_PROXY_OPENAI_AUTH_HEADER : ${LLM_PROXY_OPENAI_AUTH_HEADER:-Authorization}
331+ LLM_PROXY_OPENAI_USE_BEARER : ${LLM_PROXY_OPENAI_USE_BEARER:-true}
332+ # The LLM key + model are per-workspace (AgentConfig, encrypted at rest), not env-based.
333+ # A preview starts from an empty/seeded DB with no admin to fill that in, so seed a default
334+ # AgentConfig at boot — without a model name the Pi provider is never registered and the
335+ # mentor silently has no LLM.
336+ AGENT_DEFAULT_CONFIG_ENABLED : ${AGENT_DEFAULT_CONFIG_ENABLED:-false}
337+ AGENT_DEFAULT_CONFIG_NAME : ${AGENT_DEFAULT_CONFIG_NAME:-Default}
338+ AGENT_DEFAULT_CONFIG_PROVIDER : ${AGENT_DEFAULT_CONFIG_PROVIDER:-OPENAI}
339+ AGENT_DEFAULT_CONFIG_MODEL_NAME : ${AGENT_DEFAULT_CONFIG_MODEL_NAME:-}
340+ AGENT_DEFAULT_CONFIG_API_KEY : ${AGENT_DEFAULT_CONFIG_API_KEY:-}
341+ AGENT_DEFAULT_CONFIG_BASE_URL : ${AGENT_DEFAULT_CONFIG_BASE_URL:-}
228342 # Monitoring (relaxed for previews)
229343 MONITORING_TIMEFRAME : ${MONITORING_TIMEFRAME:-14}
230344 MONITORING_RUN_ON_STARTUP : ${MONITORING_RUN_ON_STARTUP:-false}
@@ -234,19 +348,25 @@ services:
234348 LEADERBOARD_NOTIFICATION_ENABLED : " false"
235349 # Observability (optional)
236350 SENTRY_DSN : ${SENTRY_DSN:-}
237- # Webhook auto-registration. Override (not the APPLICATION_HOST_URL default) because previews
238- # route webhooks via PREVIEW_DOMAIN, not the webapp's SERVICE_FQDN_WEBAPP. Bare origin:
239- # GitLabWebhookService appends /webhooks/gitlab itself.
351+ # Webhook auto-registration. Points at the shared-infra receiver's own host, not this
352+ # deploy's origin: webhook-server lives in the shared-infra stack and is routed there.
353+ # Bare origin — GitLabWebhookService appends /webhooks/gitlab itself.
240354 WEBHOOK_SECRET : ${WEBHOOK_SECRET:-}
241- WEBHOOK_EXTERNAL_URL : https://${PREVIEW_DOMAIN:?Required }
355+ WEBHOOK_EXTERNAL_URL : ${WEBHOOK_EXTERNAL_URL:- https://${PREVIEW_DOMAIN} }
242356 # thc healthcheck target: actuator liveness on the management port.
243357 THC_PORT : " 8080"
244358 THC_PATH : /actuator/health/liveness
359+ volumes :
360+ - /var/run/docker.sock:/var/run/docker.sock
361+ - git-repos:/data/git-repos
362+ - appserver-logs:/var/log/hephaestus
245363 depends_on :
246364 postgres :
247365 condition : service_healthy
248366 seed-loader :
249367 condition : service_completed_successfully
368+ volume-init :
369+ condition : service_completed_successfully
250370 # thc healthcheck (see docker/compose.app.yaml).
251371 healthcheck :
252372 test : ["CMD", "/workspace/health-check"]
@@ -271,23 +391,24 @@ services:
271391 args :
272392 COOLIFY_BRANCH : ${COOLIFY_BRANCH:-}
273393 SOURCE_COMMIT : ${SOURCE_COMMIT:-}
274- hostname : webapp
275394 restart : unless-stopped
276395 environment :
277396 APPLICATION_VERSION : preview
278397 # Coolify provides SERVICE_FQDN_WEBAPP automatically
279398 APPLICATION_CLIENT_URL : https://${SERVICE_FQDN_WEBAPP}
280399 GIT_BRANCH : ${COOLIFY_BRANCH:-}
281400 GIT_COMMIT : ${SOURCE_COMMIT:-}
282- # Coolify provides SERVICE_FQDN_APPLICATION_SERVER (e.g., {{pr_id}}.api.example.com)
283- APPLICATION_SERVER_URL : https://${SERVICE_FQDN_APPLICATION_SERVER}
401+ # The API's own host (Coolify assigns it per deploy). It is a sibling of the SPA host under
402+ # the same registrable domain, so the session cookie is same-site and rides XHR normally;
403+ # CORS allows this SPA origin via hephaestus.cors.allowed-origins=${APPLICATION_HOST_URL}.
404+ APPLICATION_SERVER_URL : https://${SERVICE_FQDN_APPSERVER}
284405 TANSTACK_DEVTOOLS_ENABLED : ${TANSTACK_DEVTOOLS_ENABLED:-true}
285406 # Optional
286407 SENTRY_ENVIRONMENT : preview
287408 SENTRY_DSN : ${SENTRY_DSN:-}
288409 POSTHOG_ENABLED : " false"
289410 depends_on :
290- application-server :
411+ appserver :
291412 # Gate on app-server readiness so the SPA isn't served before the API answers.
292413 condition : service_healthy
293414 healthcheck :
@@ -307,3 +428,5 @@ services:
307428# =============================================================================
308429volumes :
309430 postgres-data :
431+ git-repos :
432+ appserver-logs :
0 commit comments