>_devkit
deploy-pipeline
skills/deploy-pipeline/

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 :dev tag moves, so without this a redeploy

can restart the old image and look like the deploy silently did nothing.

  • expose, not ports. 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 sendWhat Dokploy does
nothing (bare POST)301 Branch Not Match, deploys nothing
ref for a different branch301 Branch Not Match, deploys nothing
ref: refs/heads/<its branch> + X-GitHub-Event: push200, 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:

  1. 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.

  1. 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.