>_devkit
catalogue
Stablev1.2.0

deploy-pipeline

by @chempa

Use when setting up or changing CI/CD for a service - the dev/beta/prod branch model, GitLab pipeline, docker executor, promotion and rollback, and Dokploy deploys.

Deployment pipeline

The house standard for getting code from a push to a running container. Every service follows it, so a pipeline in one repo reads the same as any other.

The whole model rests on one idea: a branch is an environment. Not a workflow stage, not a release train. dev is what the dev environment runs, prod is what production runs, and asking "what is in prod" is answered by git log prod.

Three branches

dev  ──promote_to_beta──▶  beta  ──promote_to_prod──▶  prod
 ▲                                                       │
 └──────────────── MR, on rollback ◀────────rollback_prod─┘
BranchIsWritten by
devintegration, deploys continuouslypushes and merges
betastaging, what is about to shippromote_to_beta only
prodproductionpromote_to_prod or rollback_prod only

Rules that make it work:

  1. Only dev accepts pushes. beta and prod are protected and moved

exclusively by promotion jobs. Nobody commits to them, ever.

  1. Promotion is a fast-forward of the exact commit, not a merge or a

rebuild. The artefact tested on beta is bit-identical to the one on prod.

  1. Promotion buttons are manual, and live in a stage after deploy. A

button only becomes clickable once that commit has built and deployed successfully at the previous level.

  1. No main, no master, no staging. The branch name is the

environment name, so .env.<branch>, docker-compose.<branch>.yml, and the image tag :<branch> all derive mechanically. That only holds if the set is closed.

  1. A repo may have fewer environments, and then it has fewer branches. A

service with only prod has exactly one branch. Do not create empty environments for symmetry.

The pipeline

Four stages, in order, so a failure stops everything downstream:

test  ──▶  build  ──▶  deploy  ──▶  promote
  • test runs the suite against real dependencies in Docker
  • build_image builds and pushes two tags: :<branch> and :<short-sha>
  • deploy POSTs the Dokploy webhook and fails unless it answers 200
  • promote holds the manual buttons

Jobs run on the docker executor, with a dind service for anything that needs a daemon. Full annotated pipeline in references/pipeline.md.

Two tags per build, and why

registry.gitlab.com/<group>/<project>:dev        # what compose pulls
registry.gitlab.com/<group>/<project>:a1b2c3d    # what actually shipped

The branch tag moves; the SHA tag is immutable. When you need to know what is running, or to pin a rollback, the SHA tag is the answer. A branch tag alone cannot tell you which build it points at.

Runners

Jobs must be tagged, always:

default:
  tags:
    - self-hosted

Without a tag, GitLab offers the job to its SaaS fleet as well. Those runners are permanently warm, so they win the race every time and the self-hosted runner never picks anything up. A runner may only take a job carrying tags it all has, and the shared runners are untagged, so one tag excludes them.

The consequence to accept: jobs sit pending if the self-hosted runner is down, rather than quietly falling back to SaaS. That is the correct trade, but it is the thing that will confuse you at 2am. A "stuck" pipeline usually means the runner is not up.

Build-time versus runtime config

The distinction that causes the most wasted debugging:

KindLives inReaches the app via
Build-time, non-secret.env.<branch>, committeddocker build --build-arg
Runtime, secretDokploy service environmentcontainer env

*NEXT_PUBLIC_ is compiled into the bundle at build time.** Setting it in docker-compose.yml is a silent no-op that reads as configuration. Each environment must build its own image. This has cost real time more than once: a wrong value produced install commands pointing at a host that did not exist, and no runtime override was possible.

Secrets never go in a committed file, whatever it is named. references/runtime.md covers compose, env files, Dokploy, and the webhook.

Dokploy: the branch must be in the webhook body

Not optional, and it fails silently if you get it wrong.

Dokploy's compose webhook emulates a GitHub push hook. Each compose service is wired to one branch, and on every call Dokploy compares the ref in the JSON body against that branch. A bare POST to the hook URL sends no ref, so the comparison fails and it replies:

301  Branch Not Match

and deploys nothing. curl -f does not treat a 3xx as an error, so the job exits 0 and the pipeline goes green having deployed nothing at all. You find out when the change is not live and the pipeline says it is.

So every deploy call must carry both:

-H "X-GitHub-Event: push"
-d "{\"ref\":\"refs/heads/$CI_COMMIT_BRANCH\",\"repository\":{\"full_name\":\"$CI_PROJECT_PATH\"}}"

and must assert the status code explicitly, because the exit code will not:

[ "$CODE" = "200" ] || { echo "Deploy webhook rejected"; exit 1; }

Two consequences:

  • **refs/heads/$CI_COMMIT_BRANCH must match the branch on that Dokploy

service exactly.** This is the second reason the branch name and the environment name have to be the same word.

  • One hook per environment. The hook id identifies the compose service, so

DEPLOY_HOOK is set per branch in the job's rules:, not once at the top.

Full job in references/runtime.md.

The CLI is not a deploy path

Dokploy also has a CLI, @dokploy/cli, and it is worth being deliberate about what it is for. Deploys go through the webhook. The CLI is for asking a server what it is running, and for one-off administration there is no hook for.

dokploy auth -u https://dokploy.<domain> -t <api-key>
dokploy project all

Anything deployed by hand is invisible to git log, which is the one question the branch-is-an-environment model exists to answer. If a release needs CLI commands to go out, the pipeline is missing something.

Three things about it that waste an afternoon if you meet them cold: it stores your API key inside the npm package directory, world-readable, so upgrading logs you out; its command names are generated from the server's API, so it is project all and never project list; and --version prints a hardcoded string that is not the installed version. Install, auth, env-var setup and the raw API underneath are in references/cli.md.

Promotion and rollback

Manual buttons in the pipeline UI. Rollback is the interesting one: it does not force-push or reset. It adds a new forward commit whose tree equals the previous release, so branch protection holds, the pipeline re-runs, and Dokploy ends up with a freshly built image matching a consistent compose file.

It also keeps .gitlab-ci.yml at the current version rather than reverting it, because the pipeline definition must only move forward, and opens an MR carrying the revert back to dev so the next promotion does not undo it.

Mechanics, plus the one-time PROMOTE_TOKEN setup, in references/promotion.md.

Setting up a new service

  1. Branches: dev, then beta and prod when those environments exist
  2. Protect beta and prod; allow the promote token to push them
  3. PROMOTE_TOKEN as a masked, non-protected CI variable
  4. .env.<branch> per environment, committed, build-time values only
  5. docker-compose.<branch>.yml per environment, pulling :<branch>
  6. Dokploy compose service per environment, wired to that branch, with the

secrets set there

  1. Copy the pipeline, change the DEPLOY_HOOK values
  2. Push to dev and watch it build, deploy, and offer the promote button

Maintainability

A pipeline is code, and it rots faster than application code because nobody reads it until it breaks.

The branch names are a one-way door. Everything derives mechanically from them: .env.<branch>, docker-compose.<branch>.yml, the image tag, and the refs/heads/ value Dokploy compares against. That is what makes the model coherent, and it is also why renaming an environment later is a coordinated change across the repo, the registry, and someone else's dashboard. Decide the set once, keep it closed.

Copied pipelines drift. Step 7 of the setup is "copy the pipeline", which is honest and is also how twelve services end up with twelve slightly different .gitlab-ci.yml files. A fix to the deploy assertion lands in one of them. Two things keep it manageable:

  • Extract the shared parts into an include: from a central CI templates

repo, pinned to a ref. Then a fix is one merge request, and each service's file is only the values that genuinely differ.

  • When you do copy, copy wholesale and change only the variables. A

hand-adapted pipeline is one nobody can diff against its source.

Keep job scripts short enough to read. Twenty lines of inline shell in a script: block is untestable, unrunnable locally, and debugged only by pushing commits. Move anything non-trivial into a checked-in script the pipeline calls, which you can run on your laptop.

The rollback path is a maintainability property, not a safety feature. A forward-commit rollback that keeps branch protection intact and re-runs the pipeline means reverting is boring. Boring reverts are what let a team deploy often, and deploy frequency is what keeps each change small enough to understand. Teams that cannot roll back safely batch their releases, and batched releases are how a bad deploy becomes an unattributable outage.

Secrets need a rotation story before you need one. They live in Dokploy and in CI variables, which means "which of these is still in use" is answerable only by someone remembering. Write down what each one is for, and rotate PROMOTE_TOKEN on a schedule rather than after an incident.

See the maintainability skill for the general treatment.

Copy: no em-dashes

Never use an em-dash (U+2014, the long dash) in job names, commit messages, or pipeline output. A full stop or a colon is almost always the better edit.