references/runtime.md
Runtime: compose, env, and Dokploy
One compose file per environment
docker-compose.local.yml laptop, builds locally
docker-compose.test.yml CI test stack, throwaway
docker-compose.dev.yml remote dev, pulls :dev
docker-compose.beta.yml remote beta, pulls :beta
docker-compose.prod.yml remote prod, pulls :prod
name: myapp-dev # <product>-<env>
services:
myapp-api: # <product>-<role>
image: registry.gitlab.com/<group>/myapp-api:dev
pull_policy: always
container_name: myapp-api-dev # <product>-<role>-<env>
restart: unless-stopped
expose:
- "8000"
environment:
AUTH_SECRET: ${AUTH_SECRET}
DATABASE_URL: ${DATABASE_URL}
networks:
- dokploy-network
networks:
dokploy-network:
external: true
Details that matter:
pull_policy: always. The:devtag moves, so without this a redeploy
can restart the old image and look like the deploy silently did nothing.
expose, notports. Dokploy's proxy reaches the container over
dokploy-network. Publishing a port puts the service on the host's public interface, which is how databases end up on the internet.
- Service name carries no environment, since the file already scopes it.
Container name does, because it shares one host namespace and a collision fails at runtime.
Build-time versus runtime
The distinction that wastes the most time.
Build-time values are compiled into the artefact. For a Next.js app that is everything NEXT_PUBLIC_*. They live in a committed .env.<branch>, are read by CI, and are passed with --build-arg:
# .env.dev (committed, non-secret only)
NEXT_PUBLIC_SITE_URL=https://dev.example.com
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build
*Setting a NEXT_PUBLIC_ value in docker-compose.yml does nothing.** It is already baked into the bundle. The variable appearing in an environment: block is worse than useless, because it reads as configuration and quietly is not.
Each environment therefore builds its own image. You cannot promote an image across environments if it has a per-environment value compiled in. That is the main cost of build-time config, and the reason to keep the set of such values as small as possible.
Runtime values are read by the running process: secrets, database URLs, tokens. They are set on the Dokploy service, never committed. The compose file references them as ${VAR} so the file itself stays safe to read.
A committed .env.<branch> may contain only non-secret build-time values. A real secret there is published to the repo and its history, and the only fix is rotation.
The Dokploy webhook
Dokploy's compose webhook emulates a GitHub push hook, and the branch is part of the contract. You cannot just POST the URL.
Each compose service in Dokploy is wired to exactly one branch. On every call it reads the ref out of the JSON body and compares it to that branch:
| What you send | What Dokploy does |
|---|---|
| nothing (bare POST) | 301 Branch Not Match, deploys nothing |
ref for a different branch | 301 Branch Not Match, deploys nothing |
ref: refs/heads/<its branch> + X-GitHub-Event: push | 200, deploys |
The failure is quiet in the worst way. curl -f does not treat 3xx as an error, so a bare POST exits 0 and the pipeline goes green having deployed nothing. The symptom is a successful pipeline and an unchanged environment.
Two rules follow:
- Always send the branch, as
refs/heads/$CI_COMMIT_BRANCH, matching the
branch configured on that Dokploy service exactly. This is the second reason branch names and environment names must be the same word.
- Always assert the status code. The exit code will not do it for you.
One hook id per compose service, so DEPLOY_HOOK is selected per branch in rules: rather than set once.
Send the GitHub-shaped payload and check the status code explicitly:
deploy:
stage: deploy
image: alpine:3.20
needs:
- job: build_image
artifacts: false
before_script:
- apk add --no-cache curl
script:
- |
RESP=$(curl -sS -X POST \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-d "{\"ref\":\"refs/heads/$CI_COMMIT_BRANCH\",\"repository\":{\"full_name\":\"$CI_PROJECT_PATH\"}}" \
-w "\n%{http_code}" \
"https://dokploy.example.com/api/deploy/compose/$DEPLOY_HOOK")
CODE=$(printf '%s' "$RESP" | tail -n1)
BODY=$(printf '%s' "$RESP" | sed '$d')
echo "Dokploy ($CI_COMMIT_BRANCH -> $DEPLOY_HOOK) responded HTTP $CODE: $BODY"
[ "$CODE" = "200" ] || { echo "Deploy webhook rejected"; exit 1; }
rules:
- if: '$CI_COMMIT_BRANCH == "prod"'
variables:
DEPLOY_HOOK: <prod-hook-id>
- if: '$CI_COMMIT_BRANCH == "beta"'
variables:
DEPLOY_HOOK: <beta-hook-id>
- if: '$CI_COMMIT_BRANCH == "dev"'
variables:
DEPLOY_HOOK: <dev-hook-id>
The per-branch DEPLOY_HOOK in rules: is what keeps one job serving three environments without a conditional in the script.
The ref must match the branch the target compose service is wired to in Dokploy, or you get the 301.
Dokploy service setup
Per environment, once:
- Source: the repo, on the matching branch
- Compose file:
docker-compose.<branch>.yml - Environment tab: every runtime secret
- Domain: host, service name (the compose service key, not the container
name), container port, HTTPS on, Let's Encrypt
A missing domain entry is the failure that looks like a TLS problem: Traefik has no router for the host, so it serves its default self-signed certificate and returns a bare 404 page not found. Both symptoms, one cause.
Deploy timing
The deploy job goes green when Dokploy accepts the webhook, not when the new container is serving. Pulling and restarting takes another minute or two.
So a change that looks like it did not land usually just has not swapped yet. Confirm against something the new build actually changes rather than watching the pipeline, and give it two minutes before digging.