//PART 8 OF 10
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
- 01Part 1: Why I Built a Secure CI/CD Pipeline, and Getting the Tools Ready
- 02Part 2: Vault Secrets, Policies, and an AppRole per Environment
- 03Part 3: A Small Go App That Reads Its Secrets from a File and Refuses to Start on Bad Config
- 04Part 4: A Vault Agent Sidecar and Docker Compose, Secrets Without the App Knowing Vault Exists
- 05Part 5: The GitHub Repo and the PR Workflow as a Security Gate
- 06Part 6: Rulesets That Block Merges, and Five Demo PRs That Must Fail
- 07Part 7: Setting Up the Dev and Production Environments in Dokploy
- 08Part 8: The Deploy Workflow, Build Once, Scan, SBOM, GHCR, Then Promote to Production
- 09Part 9: An Exception Process for Security Findings You Can't Fix Yet
- 10Part 10: CI Gets Its Dokploy Token from Vault through GitHub OIDC, and Wrapping Up the Series
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
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 == SHAConcept 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 |
|---|---|---|
| the | "The image from this commit". Audit trail |
| the | What's running in dev |
| the | 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 |
|
Environment variable |
|
Deployment branch |
|
Required reviewers | A job using |
Deployment history | Every job with |
Two Dokploy API keys
In Dokploy: profile menu → Profile → API/CLI → Generate. I made two keys:
Field | Key 1 | Key 2 | Why |
|---|---|---|---|
Name |
|
| Obvious which one to revoke |
Prefix |
|
| 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.
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=branchThe parts that matter:
custom_branch_policies: trueplusdeployment-branch-policieslimits 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 befalse. Withtrueand 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:
gh api repos/$R/environments --jq '.environments[] | {name, rules: [.protection_rules[].type], admins_bypass: .can_admins_bypass}'{"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
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 clipboardWithout --body, gh prompts for the value with hidden input, so the key never lands in your shell history.
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.comWhy 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:
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://dokploy.yourdomain.com/api/compose.deployCheck: 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.
#!/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"doneDon'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 |
|---|---|
| Input validation. An empty variable stops the script with a clear message instead of quietly running with an empty string |
| The JSON is built with |
| HTTP 4xx/5xx becomes a failing exit, but the response ( |
| The header is read from a process substitution. |
| While the app restarts, curl fails. That's expected while waiting, so |
| 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
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-goon: pushtodevelopmentandmain. 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)
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
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.shenvironment: 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)
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/nullThis 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
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.shAt 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
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_OKactionlint 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:
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 watchThat 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:
{"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:
git switch development && git pull --ff-only && git rev-parse HEADcurl -s https://secure-cicd-dev.yourdomain.com/versionYou can download the SBOM and peek inside:
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.jsonIt lists every component in the image: the distroless packages and Go modules such as github.com/golang-jwt/jwt/v5.
Promoting to production
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 --mergeDon'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.
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:
promoteis marked rejected, with not a single step run.Production
/versionstill shows the old SHA.The
:productiontag 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 |
|---|---|---|
| API key wrong, expired, or revoked | Generate a new key, |
|
| 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 |
| App crashed after the deploy | Logs for the app and vault-agent containers |
| Merged with squash/rebase | The |
| The dev deploy for that SHA failed or hasn't finished | Wait for dev to go green, then re-run failed jobs on the |
|
| Re-apply the production environment with |
| Secret created at repo level, not environment level |
|
Limitations I accept
Item | Way forward |
|---|---|
Production reviewer = author of the change | On a team: a reviewer from another team, |
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 |
The SBOM is only a 90-day artifact | Later, as an attestation in the registry |
| CODEOWNERS on |
Recap
GitHub Environments
development(only thedevelopmentbranch) andproduction(onlymain, 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 intops, then waits until/versionequals the expected commit.deploy.ymlwith four jobs:build(Trivy, Syft SBOM, pushsha-<SHA>),deploy-dev,resolve(second parent, must have succeeded in dev), andpromote(theproductiontag 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.