Part 8: The Deploy Workflow, Build Once, Scan, SBOM, GHCR, Then Promote to Production

· 22 min read

Indonesia
//TUTORIAL · 8 OF 10 — A Secure CI/CD Pipeline with Go, GitHub Actions, Vault, and Dokploy

By the end of Part 7 every piece of the pipeline existed, but nothing connected them. PRs are guarded by the security gate (Parts 5 and 6), and Dokploy has two services, dev and production (Part 7). What's missing is who presses the deploy button. I turned Dokploy's auto-deploy off on purpose, so right now nothing deploys at all.

In this part we write deploy.yml. A merge into development builds the image, scans it, generates an SBOM, pushes it to GHCR, and deploys it to dev. A merge into main doesn't rebuild: it finds the image that already passed dev, waits for my approval, and promotes that same image to production.

For the Dokploy API key, this part uses Stage A: the key lives in a GitHub Environment secret. That's deliberate, to get the pipeline working first. In Part 10 I delete that secret and replace it with OIDC to Vault, so GitHub stores no credential at all.

What we end up with

text
PR merged into development  └─► push development ─► deploy.yml        build          docker build (COMMIT = SHA)            ┐          Trivy image gate (CRITICAL)            │ image built ONCE          Syft → CycloneDX SBOM (artifact)       │          push ghcr.io/...:sha-<SHA>             │        deploy-dev  [environment: development]   │          tag :development → sha-<SHA>           │          Dokploy compose.deploy (dev)           │          smoke test: /version == SHA            │                                                 │PR merged development → main                     │  └─► push main ─► deploy.yml                    │        resolve                                  │          2nd parent of merge commit = dev SHA   │          that SHA MUST have deployed to dev     │        promote  [environment: production]       │          ⏸ waiting for APPROVAL                 │          tag :production → sha-<SHA>  ◄─────────┘ the SAME image          Dokploy compose.deploy (prod)          smoke test: /version == SHA

Concept first: build once, promote

The analogy that works best for me is a pharmaceutical plant. A batch is made and tested in the lab. If it passes, that same batch ships to the pharmacy. The plant doesn't make a fresh batch for the pharmacy, because a fresh batch hasn't been tested.

If production rebuilt from main, the result could differ from what was tested in dev. The base image may have moved, a build tool may be a different version. What got approved wouldn't be what's running.

So production never builds. It only moves the :production tag onto the sha-<SHA> image that already ran in dev. The tag move uses docker buildx imagetools create, and I checked against a local registry that it keeps the same digest.

There are three tags in GHCR:

Tag

Who moves it

Meaning

sha-<full SHA>

the build job, once, never changed

"The image from this commit". Audit trail

development

the deploy-dev job

What's running in dev

production

the promote job, after approval

What's running in production

My original plan set the development tag in the build job. I moved it to deploy-dev to mirror production: build only pushes sha-<SHA>, and each environment tag is moved by the job that actually deploys that environment.

Warning

The production tag only moves after approval

The order matters. If you move :production first and ask for approval second, and the approval is rejected, the tag already points at an image nobody approved. The next container restart pulls it. That's why the tag move lives inside the job that waits for approval.

GitHub Environments

An environment in GitHub is a deploy target with its own rules. This is what I use:

Feature

Used for

Environment secret

DOKPLOY_API_KEY is readable only by jobs that declare that environment:. The build job can't read it

Environment variable

DOKPLOY_URL, DOKPLOY_COMPOSE_ID, APP_URL. Same names, different values per env

Deployment branch

development only from the development branch, production only from main

Required reviewers

A job using production stops until someone approves

Deployment history

Every job with environment: is recorded as a deployment (SHA, status). The resolve job relies on this

Two Dokploy API keys

In Dokploy: profile menu → Profile → API/CLI → Generate. I made two keys:

Field

Key 1

Key 2

Why

Name

ci-development

ci-production

Obvious which one to revoke

Prefix

scg_dev

scg_prod

Not secret. If one leaks into a log, you know immediately whose it is

Expiration

90 days

same

Not Never. An expiry forces rotation

Rate limit

10 requests per hour

same

The workflow calls Dokploy once per deploy

Why two keys when both are valid for the whole Dokploy account? So they can be revoked separately. If the dev key leaks, revoke ci-development and production keeps running. Dokploy's logs also show which key was used.

Tip

If you hit HTTP 429

While debugging and re-running the workflow over and over, the 11th request in an hour gets rejected. That's the rate limit doing its job. Wait out the window, or raise the limit temporarily.

Create the environments through the API

I create environments with gh api instead of clicking through the UI, so the setup is a command I can rerun, same as the rulesets in Part 6.

bash
R=dimss113/secure-cicd-goME=$(gh api user --jq .id)   # reviewers are given as a numeric ID, not a username gh api -X PUT repos/$R/environments/development --input - <<'EOF'{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}EOFgh api -X POST repos/$R/environments/development/deployment-branch-policies -f name=development -f type=branch gh api -X PUT repos/$R/environments/production --input - <<EOF{  "wait_timer": 0,  "prevent_self_review": false,  "can_admins_bypass": false,  "reviewers": [{"type": "User", "id": $ME}],  "deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}EOFgh api -X POST repos/$R/environments/production/deployment-branch-policies -f name=main -f type=branch

The parts that matter:

  • custom_branch_policies: true plus deployment-branch-policies limits which branches may use the environment. A workflow running from any other branch can't read its API key.

  • can_admins_bypass: false: a repo admin (me) can't skip the approval. That matches the Part 6 rulesets, whose bypass list is empty.

  • prevent_self_review: false: I may approve a deploy I triggered myself. On a solo project this has to be false. With true and me as the only reviewer, the deploy waits forever.

Note

Self-review is a known limitation

On a real team, prevent_self_review should be true with a reviewer from another team (segregation of duties). The person who wrote the change isn't the person who approves the release. That's impossible on a solo project, so I record it as a limitation. The approval still earns its place: a production deploy becomes a deliberate, recorded decision (who, when, with what comment), not a side effect of a merge.

Check:

bash
gh api repos/$R/environments --jq '.environments[] | {name, rules: [.protection_rules[].type], admins_bypass: .can_admins_bypass}'
text
{"admins_bypass":true,"name":"development","rules":["branch_policy"]}{"admins_bypass":false,"name":"production","rules":["required_reviewers","branch_policy"]}

development has no reviewer, so admin bypass doesn't matter there.

Fill in secrets and variables

bash
gh secret set DOKPLOY_API_KEY --env development --repo $R   # paste the ci-development keygh secret set DOKPLOY_API_KEY --env production  --repo $R   # paste the ci-production keypbcopy < /dev/null                                          # clear the clipboard

Without --body, gh prompts for the value with hidden input, so the key never lands in your shell history.

bash
gh variable set DOKPLOY_URL        --env development --repo $R --body "https://dokploy.yourdomain.com"gh variable set DOKPLOY_COMPOSE_ID --env development --repo $R --body "<dev composeId>"gh variable set APP_URL            --env development --repo $R --body "https://secure-cicd-dev.yourdomain.com"# repeat for --env production with the prod composeId and https://secure-cicd.yourdomain.com

Why are the composeId and URL variables rather than secrets? Because they aren't secret: without the API key they're useless. Make them secrets and the logs fill with *** and get hard to debug. Don't make everything a secret, or nobody can tell which ones really need guarding.

Warning

The Dokploy panel must be on HTTPS

The workflow sends the API key to the Dokploy panel. If the panel is still http://<VPS_IP>:3000, the key crosses the internet unencrypted. Give the panel a domain and a Let's Encrypt certificate first, then use that https:// URL as DOKPLOY_URL, without a trailing /.

A quick way to check DOKPLOY_URL without a key:

bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://dokploy.yourdomain.com/api/compose.deploy

Check: it must print 401. The endpoint exists, HTTPS is valid, and all it needs is a key. 404 means the URL is wrong.

The deploy script: scripts/dokploy-deploy.sh

My original plan put the deploy logic straight into the workflow. I moved it into one script because two jobs use it (dev and prod) and because a script is easier to test. I tested it against a fake server for three cases: success, wrong commit, and a bad API key.

scripts/dokploy-deploy.sh bash
#!/usr/bin/env bash# Asks Dokploy to deploy one compose service, then waits until the app reports the expected commit.# A deploy only counts once /version proves the scanned commit is the one running.# Env: DOKPLOY_URL DOKPLOY_API_KEY DOKPLOY_COMPOSE_ID APP_URL EXPECTED_COMMIT [TIMEOUT_SECONDS] [POLL_SECONDS]set -euo pipefail : "${DOKPLOY_URL:?DOKPLOY_URL is not set}": "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is not set}": "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is not set}": "${APP_URL:?APP_URL is not set}": "${EXPECTED_COMMIT:?EXPECTED_COMMIT is not set}"timeout="${TIMEOUT_SECONDS:-300}"poll="${POLL_SECONDS:-10}" body="$(jq -n --arg id "$DOKPLOY_COMPOSE_ID" --arg sha "$EXPECTED_COMMIT" \  '{composeId: $id, title: ("CI deploy " + $sha[0:7])}')" # The key is read from a file descriptor, not passed as an argument, so it never shows up in the process list.curl -sS --fail-with-body --max-time 30 -X POST "$DOKPLOY_URL/api/compose.deploy" \  -H "Content-Type: application/json" \  -H @<(printf 'x-api-key: %s\n' "$DOKPLOY_API_KEY") \  --data "$body"echo deadline=$((SECONDS + timeout))while :; do  got="$(curl -sS --max-time 5 "$APP_URL/version" 2>/dev/null | jq -r '.commit // empty' 2>/dev/null || true)"  if [ "$got" = "$EXPECTED_COMMIT" ]; then    curl -sS --fail --max-time 5 "$APP_URL/health"    echo "running commit $EXPECTED_COMMIT"    exit 0  fi  if [ "$SECONDS" -ge "$deadline" ]; then    echo "::error::$APP_URL still reports '${got:-unreachable}' after ${timeout}s, expected $EXPECTED_COMMIT"    exit 1  fi  echo "waiting: app reports '${got:-unreachable}'"  sleep "$poll"done

Don't forget chmod +x scripts/dokploy-deploy.sh. Git stores that bit, so the file is executable on the runner.

A few lines worth explaining:

Line

Why

: "${VAR:?message}"

Input validation. An empty variable stops the script with a clear message instead of quietly running with an empty string

jq -n --arg ...

The JSON is built with jq, not string concatenation. --arg escapes the value, so odd characters can't break the body

--fail-with-body

HTTP 4xx/5xx becomes a failing exit, but the response ({"message":"Unauthorized"}) still gets printed

-H @<(printf ...)

The header is read from a process substitution. printf is a bash builtin, so no new process is spawned. The key never appears in ps aux, unlike -H "x-api-key: $KEY", which becomes an argument of the curl process

|| true in the loop

While the app restarts, curl fails. That's expected while waiting, so set -e must not kill the script

::error::

Shows up as a red annotation on the GitHub run page

Why the smoke test compares /version with the SHA

This is my favourite part. Dokploy's compose.deploy only queues the deploy: I read it in the Dokploy v0.30.10 source, it calls myQueue.add(...) and immediately replies "Deployment queued". The actual deploy happens after the API has answered, and it can fail: the pull fails, vault-agent can't log in, the app crashes.

Without a smoke test, the workflow goes green while the old version is still running. The Go app from Part 3 bakes the commit SHA in through -ldflags and reports it at /version. So the script waits until /version shows exactly the commit that was scanned and approved. That's the only thing that proves the deploy really happened.

As a bonus, it also catches manual deploys from the Dokploy UI. If someone deploys a different version, /version won't match, and the next deploy makes that visible.

The workflow: .github/workflows/deploy.yml

I'll show it section by section. This is the Stage A version, exactly as it was first merged. In Part 9 one line, trivyignores: .trivyignore, gets added to the Trivy step, and in Part 10 the way the API key is fetched changes.

The top

.github/workflows/deploy.yml yaml
name: deploy on:  push:    branches: [development, main] permissions: {} # One deploy per branch at a time; a newer push waits instead of cancelling a deploy halfway.concurrency:  group: deploy-${{ github.ref_name }}  cancel-in-progress: false env:  IMAGE: ghcr.io/dimss113/secure-cicd-go
  • on: push to development and main. Thanks to the Part 6 rulesets, the only way a commit lands on either branch is a merged PR that passed the gate.

  • permissions: {}: no permissions by default. Each job asks for its own.

  • cancel-in-progress: false: unlike the PR checks. Cancelling an old scan is harmless, but a deploy cut off halfway can leave the server half-updated. A newer push has to wait.

The build job (only on development)

yaml
jobs:  build:    if: github.ref_name == 'development'    runs-on: ubuntu-24.04    timeout-minutes: 20    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Build image        run: |          docker build \            --build-arg COMMIT="$GITHUB_SHA" \            --build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \            --label org.opencontainers.image.source="https://github.com/$GITHUB_REPOSITORY" \            --label org.opencontainers.image.revision="$GITHUB_SHA" \            -t "$IMAGE:sha-$GITHUB_SHA" .      - name: Trivy image (gate)        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0        with:          version: v0.76.1          scan-type: image          image-ref: ${{ env.IMAGE }}:sha-${{ github.sha }}          severity: CRITICAL          ignore-unfixed: true          format: table          exit-code: 1      - name: Install syft        env:          SYFT_VERSION: 1.54.1          SYFT_SHA256: c069905b391cc4c20a5ba65ad5c10be2a7ba074f8ea6ad203e24d14e303dad47        run: |          curl -sSfL -o syft.tar.gz \            "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"          echo "${SYFT_SHA256}  syft.tar.gz" | sha256sum --check -          tar -xzf syft.tar.gz syft          rm syft.tar.gz      - name: SBOM (CycloneDX)        run: ./syft scan "docker:$IMAGE:sha-$GITHUB_SHA" -o cyclonedx-json=sbom.cdx.json      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2        with:          name: sbom-${{ github.sha }}          path: sbom.cdx.json          retention-days: 90      - name: Push sha-<commit> to GHCR        env:          REGISTRY_TOKEN: ${{ github.token }}        run: |          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin          docker push "$IMAGE:sha-$GITHUB_SHA"          docker logout ghcr.io          digest="$(docker buildx imagetools inspect "$IMAGE:sha-$GITHUB_SHA" | sed -n 's/^Digest: *//p')"          echo "Pushed \`$IMAGE:sha-$GITHUB_SHA\` (\`$digest\`)" >> "$GITHUB_STEP_SUMMARY"

The order is build → scan → SBOM → push. If Trivy fails, the image never reaches the registry. Everything in GHCR has passed the scan and has an SBOM.

Why run Trivy again when the PR was already scanned? Because a new CVE can be published between opening the PR and merging it. This is the last scan before the image is published, with the same rule: a CRITICAL that already has a fix fails the job.

For Syft I don't use a third-party action. The binary is downloaded at a pinned version and verified with sha256sum --check, same as Gitleaks in Part 5. If someone swaps the file on GitHub Releases, the checksum won't match and the job stops. I picked 1.54.1 on purpose rather than a release only a few days old: a brand-new release hasn't stood the test of time yet.

The SBOM is CycloneDX JSON, kept as a run artifact for 90 days under a name that includes the SHA. The GHCR token is github.token (issued per run), passed through env: and --password-stdin, so it never appears in process arguments. The pushed digest goes to the run Summary as an audit record of SHA ↔ digest.

The deploy-dev job

yaml
  deploy-dev:    needs: build    runs-on: ubuntu-24.04    timeout-minutes: 15    environment:      name: development      url: ${{ vars.APP_URL }}    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Point the development tag at this image        env:          REGISTRY_TOKEN: ${{ github.token }}        run: |          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin          docker buildx imagetools create -t "$IMAGE:development" "$IMAGE:sha-$GITHUB_SHA"          docker logout ghcr.io      - name: Deploy and smoke test        env:          DOKPLOY_URL: ${{ vars.DOKPLOY_URL }}          DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}          DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }}          APP_URL: ${{ vars.APP_URL }}          EXPECTED_COMMIT: ${{ github.sha }}        run: scripts/dokploy-deploy.sh

environment: development lets this job read that environment's secret and variables, and GitHub records it as a deployment. DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} is Stage A: the key enters as an env var for this one step only, and the log masks it as ***.

After compose.deploy, Dokploy runs docker compose up -d. The dev compose file uses pull_policy: always, so the new :development image is pulled, and because its digest changed, the app container is recreated.

The resolve job (only on main)

yaml
  resolve:    if: github.ref_name == 'main'    runs-on: ubuntu-24.04    timeout-minutes: 5    permissions:      contents: read      deployments: read    outputs:      sha: ${{ steps.find.outputs.sha }}    steps:      - name: Find the development commit this merge brought in        id: find        env:          GH_TOKEN: ${{ github.token }}        run: |          parents="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.parents | length')"          if [ "$parents" != 2 ]; then            echo "::error::main must only change through a merge commit from development"            exit 1          fi          sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.parents[1].sha')"          echo "sha=$sha" >> "$GITHUB_OUTPUT"          echo "Promoting development commit \`$sha\`" >> "$GITHUB_STEP_SUMMARY"      - name: That commit must have deployed successfully to development        env:          GH_TOKEN: ${{ github.token }}          SHA: ${{ steps.find.outputs.sha }}        run: |          id="$(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=development&sha=$SHA" --jq '.[0].id // empty')"          state="$(if [ -n "$id" ]; then gh api "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" --jq '.[0].state'; fi)"          if [ "$state" != success ]; then            echo "::error::$SHA has no successful development deployment (state: '${state:-none}')"            exit 1          fi          docker buildx imagetools inspect "$IMAGE:sha-$SHA" >/dev/null

This job answers one question: which image should be promoted?

A merge commit has two parents. The first is the previous main, the second is the tip of the branch being merged, which is development, and also the head SHA of the development → main PR. That's the commit whose image was built and tested in dev. If the commit on main isn't a merge commit (say the ruleset was switched off and someone pushed directly, or squashed), the job stops and promotes nothing.

My original plan only checked that the sha-<SHA> image existed in GHCR. That turned out not to be enough. The image is pushed in the build job, before the dev deploy. If the dev deploy or the smoke test fails, the image is still there. So "the image exists" could let through a commit that failed in dev. That's why resolve asks GitHub: "is the development deployment for this SHA in state success?" If not, promotion stops before it even asks for approval.

The promote job

yaml
  promote:    needs: resolve    runs-on: ubuntu-24.04    timeout-minutes: 15    environment:      name: production      url: ${{ vars.APP_URL }}    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      # Only after approval: the production tag never points at an image nobody approved.      - name: Point the production tag at the approved image        env:          REGISTRY_TOKEN: ${{ github.token }}          SHA: ${{ needs.resolve.outputs.sha }}        run: |          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin          docker buildx imagetools create -t "$IMAGE:production" "$IMAGE:sha-$SHA"          docker logout ghcr.io      - name: Deploy and smoke test        env:          DOKPLOY_URL: ${{ vars.DOKPLOY_URL }}          DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}          DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }}          APP_URL: ${{ vars.APP_URL }}          EXPECTED_COMMIT: ${{ needs.resolve.outputs.sha }}        run: scripts/dokploy-deploy.sh

At environment: production the workflow stops and waits for approval. Not a single step has run yet, so the production secret hasn't been read and the tag hasn't moved. Only after approval does :production get pointed at the exact image that was tested in dev.

Look at EXPECTED_COMMIT. Production must report the development SHA from resolve, not github.sha (the merge commit on main). That merge commit was never built, so it can never show up at /version. Use github.sha here by mistake and the smoke test times out every time.

Local checks before committing

bash
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12docker run --rm -v "$PWD/scripts:/mnt" koalaman/shellcheck:v0.10.0 /mnt/dokploy-deploy.sh && echo SC_OKbash -n scripts/dokploy-deploy.sh && echo SYNTAX_OK

actionlint catches typo'd keys and broken ${{ }} expressions. ShellCheck catches bash bugs like missing quotes. Check: actionlint prints nothing, then SC_OK and SYNTAX_OK. I also made sure both files pass Semgrep and Gitleaks, so the PR isn't blocked by our own gate.

The first dev deploy

Commit, open a PR into development, wait for every check to go green, and merge with a merge commit:

bash
git add scripts/dokploy-deploy.sh .github/workflows/deploy.ymlgit commit -m "ci: build, scan and deploy through Dokploy with promotion to production"git push -u origin ci/deploy-workflowgh pr create --base development --title "ci: deploy workflow with build-once promotion" --body "..."gh pr checks --watchgh pr merge --merge --delete-branchgh run watch

That merge is a push to development, so deploy.yml runs for the first time and replaces the bootstrap image from Part 7.

Warning

My first run failed on the GHCR push

The error was denied: permission_denied: write_package. Login succeeded, some layers said already exists, then it was refused. The cause: the package was created from my Mac during the Part 7 bootstrap, so the repo's GITHUB_TOKEN didn't automatically get write access, even though the source label linked the package to the repo. The fix: package settings → Manage Actions access → add the secure-cicd-go repo with the Write role, then gh run rerun <run-id> --failed.

Check: build and deploy-dev are green, resolve and promote are skipped. The smoke test log looks roughly like this:

text
{"success":true,"message":"Deployment queued","composeId":"..."}waiting: app reports '<bootstrap SHA>'waiting: app reports 'unreachable'{"status":"ok"}running commit <development SHA>

From the laptop, the commit at /version must equal git rev-parse HEAD on development:

bash
git switch development && git pull --ff-only && git rev-parse HEADcurl -s https://secure-cicd-dev.yourdomain.com/version

You can download the SBOM and peek inside:

bash
RUN_ID=$(gh run list --workflow deploy.yml --branch development --status success --limit 1 --json databaseId --jq '.[0].databaseId')SHA=$(gh run view "$RUN_ID" --json headSha --jq .headSha)gh run download "$RUN_ID" -n "sbom-$SHA" -D /tmp/sbomjq '.components | length' /tmp/sbom/sbom.cdx.json

It lists every component in the image: the distroless packages and Go modules such as github.com/golang-jwt/jwt/v5.

Promoting to production

bash
gh pr create --base main --head development \  --title "release: promote development to production" \  --body "Promotes the development build that passed dev deploy and smoke test."gh pr checks --watchgh pr merge --merge

Don't use --delete-branch here. development must never be deleted, and the ruleset refuses it anyway.

Warning

Add the prod domain before you approve

Before this merge, main had no docker-compose.production.yml, so Dokploy's Domains tab couldn't pick a service. Now the file is on main and promote is waiting for approval. Use that pause: prod service → Domains → host secure-cicd.yourdomain.com, service app, port 8080, HTTPS with Let's Encrypt. Click Create, but don't Deploy from Dokploy. Without the domain, Traefik answers 404, the smoke test times out after 300 seconds, and promote goes red even though the container is running.

Then gh run view --web, click the Review pending deployments banner, tick production, leave a comment (for example Release d4fedbe, dev smoke test passed), and Approve and deploy.

Check: the production commit equals the dev commit, and both tags have the same digest.

bash
curl -s https://secure-cicd.yourdomain.com/versioncurl -s https://secure-cicd-dev.yourdomain.com/versiondocker buildx imagetools inspect ghcr.io/dimss113/secure-cicd-go:production | sed -n 's/^Digest: *//p'docker buildx imagetools inspect ghcr.io/dimss113/secure-cicd-go:development | sed -n 's/^Digest: *//p'

Negative test: reject the approval

The most convincing proof is watching it fail. I made one small change (a docs note), merged it into development, waited for the dev deploy to go green, then opened and merged a development → main PR. This time I clicked Reject on the run page.

Check:

  • promote is marked rejected, with not a single step run.

  • Production /version still shows the old SHA.

  • The :production tag in GHCR still points at the old digest.

After a rejection, main holds a commit that isn't running in production. That's normal in this model: main means "approved for release", and the release can wait. To ship it later, Re-run jobs on that run and approve.

When things go wrong

Symptom

Cause

Fix

401 + Unauthorized from Dokploy

API key wrong, expired, or revoked

Generate a new key, gh secret set again

404 from Dokploy

DOKPLOY_URL ends in /, giving //api

Drop the trailing /

Smoke test times out, app still on old SHA

Dokploy deploy failed or is still queued

Check the Deployments tab in Dokploy

unreachable forever

App crashed after the deploy

Logs for the app and vault-agent containers

main must only change through a merge commit

Merged with squash/rebase

The main ruleset allows merge commits only (Part 6)

has no successful development deployment

The dev deploy for that SHA failed or hasn't finished

Wait for dev to go green, then re-run failed jobs on the main run

promote waits forever

prevent_self_review: true and you're the only reviewer

Re-apply the production environment with false

DOKPLOY_API_KEY env is empty

Secret created at repo level, not environment level

gh secret list --env development

Limitations I accept

Item

Way forward

Production reviewer = author of the change

On a team: a reviewer from another team, prevent_self_review: true

The Dokploy API key is valid for the whole account

Two separate keys with an expiry. Part 10 moves the key into Vault via OIDC

Deploys use a tag, not a digest

The tag only moves after approval, plus the /version smoke test. Digests and Cosign belong to another project

The SBOM is only a 90-day artifact

Later, as an attestation in the registry

deploy.yml can be changed through a PR

CODEOWNERS on /.github/, or a required workflow from a central repo

Recap

  • GitHub Environments development (only the development branch) and production (only main, approval required, no admin bypass).

  • Two Dokploy API keys with an expiry and a rate limit, stored as environment secrets (Stage A).

  • scripts/dokploy-deploy.sh: triggers the deploy without leaking the key into ps, then waits until /version equals the expected commit.

  • deploy.yml with four jobs: build (Trivy, Syft SBOM, push sha-<SHA>), deploy-dev, resolve (second parent, must have succeeded in dev), and promote (the production tag moves only after approval).

  • Proof: dev and production run the same digest, and a rejected approval leaves production untouched.

The pipeline now runs from PR all the way to production. But a strict gate brings its own problem: what if a CVE has no fix yet, or a finding turns out to be a false positive, and the feature still has to ship? In Part 9 I build an exception process: every exception needs an owner, a reason, and an expiry date, and CI enforces all three.

Related articles