Part 7: Setting Up the Dev and Production Environments in Dokploy

· 21 min read

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

Up to Part 6, everything we built lives on GitHub: a PR workflow that checks the code, and rulesets that refuse a merge when a check is red. Not a single container is running on a server yet.

In this part we set up where the app runs. I use Dokploy on a VPS, with two Compose services: one for dev, one for production. Each gets its own Vault credentials, its own HTTPS domain, and, most importantly, auto-deploy turned off. Only CI is allowed to trigger a deploy, and we write that CI workflow in Part 8.

There's a chicken-and-egg problem here. The dev compose file pulls ghcr.io/dimss113/secure-cicd-go:development, but CI only starts building that image in Part 8. So for dev I build one bootstrap image from my laptop, under strict rules. Production is deliberately not deployed at all until Part 8.

As a bonus, this dev deploy is also the first proof of Part 4 against the real Vault, not a local dev Vault.

Where we end up

text
Dokploy (VPS)└── Project secure-cicd-go    ├── secure-cicd-go-dev   (Compose, branch development, docker-compose.development.yml)    │     env   : VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID  ← DEV AppRole    │     domain: https://secure-cicd-dev.yourdomain.com → app:8080 (Let's Encrypt)    │     status: running (bootstrap image)    │    └── secure-cicd-go-prod  (Compose, branch main, docker-compose.production.yml)          env   : VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID  ← PRODUCTION AppRole          domain: https://secure-cicd.yourdomain.com → app:8080 (added in Part 8)          status: not deployedAuto deploy: OFF on both. Only CI triggers a deploy (Part 8).

Before you start, check two things. The DNS from Part 1 resolves (dig +short secure-cicd-dev.yourdomain.com should return the VPS IP), and the Vault CLI in your terminal is logged in as admin, as in Part 2. We keep using that terminal throughout this part.

What Dokploy does when you press Deploy

Before clicking anything, I wanted to understand what actually happens. The docs don't go into much detail, so I read the Dokploy v0.30.10 source directly (the compose builder). Roughly:

text
1. git clone the repo (selected branch) → /etc/dokploy/compose/<appName>/code/2. Read the compose file, then INJECT:     • Traefik labels (router, domain, HTTPS, certresolver) into the service that has a domain     • the dokploy-network network into that service3. Write the Environment tab → a .env file next to the compose file4. Run:     docker compose -p <appName> --env-file <dir>/.env -f <compose> up -d --build --remove-orphans5. Traefik sees the new labels → https://domain is routed to the app container on 8080

Think of Dokploy as a contractor showing up at the server. It takes the blueprint from Git, puts up a sign (the domain labels), leaves the keys in a drawer (.env), and starts the machine.

Here's the dev compose file from the repo. The production file is identical except for APP_ENV: production and the :production image tag:

docker-compose.development.yml yaml
# Development stack on Dokploy. Dokploy writes VAULT_ADDR, VAULT_ROLE_ID and VAULT_SECRET_ID to .env# next to this file. APP_ENV is pinned here, never read from .env, so a wrong value cannot pull# another environment's secrets.services:  vault-agent:    build: ./vault-agent    restart: unless-stopped    environment:      APP_ENV: development      VAULT_ADDR: ${VAULT_ADDR:?set VAULT_ADDR}      VAULT_ROLE_ID: ${VAULT_ROLE_ID:?set VAULT_ROLE_ID}      VAULT_SECRET_ID: ${VAULT_SECRET_ID:?set VAULT_SECRET_ID}    volumes:      - vault_auth:/vault/auth      - config_vol:/vault/out    read_only: true    cap_drop: [ALL]    security_opt: [no-new-privileges:true]   app:    image: ghcr.io/dimss113/secure-cicd-go:development    pull_policy: always    restart: unless-stopped    depends_on:      vault-agent:        condition: service_healthy    # Only the rendered config; the AppRole volume is never mounted here.    volumes:      - config_vol:/run/config:ro    expose:      - "8080"    read_only: true    cap_drop: [ALL]    security_opt: [no-new-privileges:true]    networks:      - dokploy-network # tmpfs: secrets live in RAM only and vanish when the stack stops.volumes:  vault_auth:    driver_opts: {type: tmpfs, device: tmpfs, o: "uid=65532,gid=65532,mode=0700"}  config_vol:    driver_opts: {type: tmpfs, device: tmpfs, o: "uid=65532,gid=65532,mode=0700"} networks:  # Created by Dokploy; Traefik routes the domain to the app over it.  dokploy-network:    external: true

How it connects to the Dokploy steps above:

Line in compose

Filled by

VAULT_ADDR: ${VAULT_ADDR:?set VAULT_ADDR}

.env from the Environment tab. If empty, compose stops with set VAULT_ADDR

APP_ENV: development

Pinned in the file, not read from .env. A typo in the Environment tab can't make dev read production's secret paths

build: ./vault-agent

Because of --build, the vault-agent image is built on the server from the repo folder

image: ...:development + pull_policy: always

Pulled from GHCR on every deploy. This image must already exist

networks: dokploy-network

Traefik's network. Dokploy also adds it automatically, so there's no clash

Notice that only app sits on dokploy-network. That network is the inbound path from the internet through Traefik, and only app needs to receive requests. vault-agent stays on the project's default network, which can still reach out to Vault. Each container is connected only to what it needs.

And app uses expose, not ports. ports: "8080:8080" opens the port on the VPS's public IP, so anyone could hit http://<VPS_IP>:8080 with no HTTPS and without going through Traefik. expose only opens it inside the Docker network, so Traefik is the only way in.

Warning

Deploy doesn't mean restart

The command is docker compose up -d. Compose only recreates a container when something changed: a new image, changed env, or changed service config. Press Deploy with nothing changed and the containers don't restart. This matters later for secret rotation.

Create the project and the dev Compose service

In the Dokploy panel: Projects → Create Project, name secure-cicd-go. Newer Dokploy versions have environments inside a project (the default is production). If yours does, add a development environment, put the dev service there and the prod service in production.

Why one project with two services, rather than one service that switches branches? Because each Compose service has its own env (its own AppRole), its own domain, and its own appName, which prefixes containers and volumes on the server. Dev and production are fully separated: different tmpfs volumes, different credentials.

Now, in the development environment: Create Service → Compose.

Field

Value

Why

Name

secure-cicd-go-dev

The name you see in the UI

App Name

leave the generated one

Becomes the compose project name (-p), the prefix for containers and volumes

Compose Type

Docker Compose

Not Stack (Swarm). We have one server

Open the service, General tab, choose Provider: Git:

Field

Value

Repository URL

https://github.com/dimss113/secure-cicd-go.git

Branch

development

Compose Path

./docker-compose.development.yml

SSH Key

empty (public repo)

Save. Then make sure Autodeploy is OFF, and don't add a Dokploy webhook on GitHub.

Why the "Git" provider, not "GitHub"

The GitHub provider installs Dokploy's GitHub App on your account. That app gets access to the repo and can trigger a deploy on every push. The Git provider just runs git clone against a public URL. Dokploy gets no access to your GitHub account at all.

That's least privilege: Dokploy only needs to read a public repo. If the Dokploy server is compromised, the attacker doesn't get your GitHub along with it.

Why auto-deploy must be off

With it on, every push to development is deployed by Dokploy straight away, without waiting for Trivy in CI. An unscanned image could end up running on the server.

With auto-deploy off, the only path to a deploy is CI, and CI calls the Dokploy API only after every gate passes. This is the anti-goal from Part 1: the pipeline must not be skippable.

To be honest, this doesn't close every door. A Dokploy admin can still click Deploy in the UI, because Dokploy has no "deploy only from CI" setting. The mitigation: keep Dokploy accounts to a minimum, and the smoke test in Part 8 checks that /version matches the scanned commit, so a stray manual deploy shows up.

Add the dev AppRole credentials

The goal: create one fresh secret_id just for Dokploy dev, paste it into Dokploy without it ever showing on screen, and revoke every old secret_id left over from testing in Part 2.

Revoke old secret_ids

bash
ROLE=secure-cicd-go-developmentvault list auth/approle/role/$ROLE/secret-id

What you get back is a list of accessors, a kind of serial number for each secret_id. An accessor isn't secret and can't be used to log in. If you see No value found, nothing is active and you can skip this. Otherwise, revoke them all:

bash
for a in $(vault list -format=json auth/approle/role/$ROLE/secret-id | jq -r '.[]'); do  vault write auth/approle/role/$ROLE/secret-id-accessor/destroy secret_id_accessor="$a"done

The loop pulls each accessor out of the JSON output and destroys the secret_id behind it. Why bother? Every active secret_id is a "password" that still works. The unused ones only add chances to leak.

Warning

Your local Part 4 stack gets hit too

If the local stack against the real Vault is still running, it keeps going on the token it already has, but it fails to log in on restart. That's correct and expected. Create a new secret_id if you want to test locally again.

Paste into Dokploy through the clipboard

In the secure-cicd-go-dev service → Environment tab, type the skeleton:

text
VAULT_ADDR=https://vault.example.comVAULT_ROLE_ID=VAULT_SECRET_ID=

Put the role_id straight on the clipboard, then paste it after VAULT_ROLE_ID=:

bash
vault read -field=role_id auth/approle/role/$ROLE/role-id | pbcopy

Create a new secret_id, also straight to the clipboard, and paste it after VAULT_SECRET_ID=:

bash
vault write -f -field=secret_id auth/approle/role/$ROLE/secret-id | pbcopy

Then clear the clipboard:

bash
pbcopy < /dev/null

Make sure Create Env File (if your version has it) is ON, then Save.

Why | pbcopy and not read -rs like in Part 2? There, the value had to go into a shell variable for the next command. Here, it's going into a web form, so the clipboard is the shortest path. It's just as safe: the value doesn't end up in shell history, doesn't appear on screen (safe from screenshots and screen sharing), and is never written to a file.

Warning

Clipboard managers

If you use a clipboard manager like Raycast, Paste, or Maccy, it keeps clipboard history. Delete those entries from it, or pause it for this step.

Check: there should be exactly one accessor.

bash
vault list auth/approle/role/$ROLE/secret-id

Note

Where Dokploy keeps these values

From its source: in the Dokploy database (unencrypted), in the .env file next to the compose file on the VPS, and as env on the vault-agent container, visible through docker inspect. So anyone with Dokploy admin or root on the VPS can read the secret_id. That's the accepted risk of a static secret_id. Mitigations: the policy can only read its own dev paths, the Vault token TTL is 1 hour, the secret_id can be revoked at any time, and admin access is restricted. Stronger real-world patterns are response wrapping or platform auth (Kubernetes/AWS IAM), meaning a machine identity rather than a pasted secret.

Add the dev domain with HTTPS

Service secure-cicd-go-dev → Domains tab → Add Domain:

Field

Value

Why

Service Name

app

Only app receives requests. Not vault-agent

Host

secure-cicd-dev.yourdomain.com

The A record from Part 1

Path

/

Every path

Container Port

8080

The port the app exposes

HTTPS

ON

Certificate Provider

Let's Encrypt

Free, renewed automatically by Traefik

If the Service Name list is empty, hit the fetch/reload button so Dokploy reads the compose file from the branch.

The domain only goes live on the next Deploy, because the Traefik labels are written into the compose file at deploy time. And Let's Encrypt only issues a certificate once the domain already points at the VPS. If DNS is wrong at deploy time, the browser shows the fake TRAEFIK DEFAULT CERT.

A one-time dev bootstrap image from the laptop

The problem again: the dev compose file pulls ghcr.io/dimss113/secure-cicd-go:development, and that image doesn't exist until CI builds it in Part 8. With no image, the deploy fails with manifest unknown.

The fix: just this once, build from the laptop, scan it to the same standard or stricter, then push.

Warning

A controlled exception, not the normal path

  • Build from a clean development that already passed every PR gate.

  • It must pass Trivy HIGH,CRITICAL with exit code 1 before pushing.

  • Only the development tag. No sha-... tag and no production, because Part 8 promotes sha-<commit> to production. A hand-built image must never be selectable.

  • The token with package write access is revoked after the push.

  • CI's first deploy in Part 8 overwrites this image.

In a company this is called break-glass: it's logged and the reason is written down.

Start from clean code

bash
git switch developmentgit pull --ff-onlygit status --short

--ff-only refuses the pull if it would need a local merge commit, so your local copy matches GitHub exactly. git status --short must be empty. If anything changed, git stash first.

Build for linux/amd64

bash
COMMIT=$(git rev-parse HEAD)BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)IMG=ghcr.io/dimss113/secure-cicd-go docker buildx build --platform linux/amd64 \  --build-arg COMMIT="$COMMIT" \  --build-arg BUILD_TIME="$BUILD_TIME" \  --label org.opencontainers.image.source=https://github.com/dimss113/secure-cicd-go \  -t "$IMG:development" \  --load .

The parts that matter:

Part

Meaning

--platform linux/amd64

Required. My Mac is Apple Silicon (arm64), the VPS is amd64. Without it, the VPS shows exec format error

--build-arg COMMIT

Feeds ARG COMMIT in the Dockerfile, which goes through -ldflags -X into /version

--label org.opencontainers.image.source

The standard OCI label. GHCR uses it to link the package to the repo, so the repo's Actions can be granted access later

--load

Keeps the result in local Docker so it can be scanned before pushing

The build takes a minute or two, because Go is compiled under amd64 emulation.

Check:

bash
docker image inspect "$IMG:development" \  --format '{{.Architecture}} {{index .Config.Labels "org.opencontainers.image.source"}}'
text
amd64 https://github.com/dimss113/secure-cicd-go

Scan before pushing

bash
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMG:development"echo "exit=$?"

My result:

text
┌────────────────────────────────────────────────────────────┬──────────┬─────────────────┐│                           Target                           │   Type   │ Vulnerabilities │├────────────────────────────────────────────────────────────┼──────────┼─────────────────┤│ ghcr.io/dimss113/secure-cicd-go:development (debian 12.15) │  debian  │        0        ││ app                                                        │ gobinary │        0        │└────────────────────────────────────────────────────────────┴──────────┴─────────────────┘exit=0

That's stricter than the trivy-image gate in pr-checks.yml, which only blocks CRITICAL (HIGH goes into the SARIF report). An image built outside CI gets held to a higher bar. If you get exit=1, stop. Don't push.

Log in to GHCR briefly, push, revoke

My gh token only had the gist, read:org, and repo scopes. Pushing to GHCR needs write:packages, so I add it for a moment:

bash
gh auth refresh -h github.com -s write:packagesgh auth token | docker login ghcr.io -u dimss113 --password-stdindocker push "$IMG:development"docker logout ghcr.iogh auth refresh -h github.com -r write:packages

-s adds the scope (a browser opens to confirm), gh auth token | docker login --password-stdin passes the token over stdin so it never shows on screen or in history, and -r on the last line removes the scope again. Write access exists only while it's needed.

Check: the last line of the push reads development: digest: sha256:... size: ....

Make the package public and grant Actions access

A new GHCR package is always private. In this setup Dokploy pulls the image without logging in, so the package has to be public.

  1. Open the secure-cicd-go package page on your GitHub profile → Package settings.

  2. Danger Zone → Change visibility → Public, and type the package name to confirm.

  3. Under Manage Actions access, make sure the secure-cicd-go repo is listed with the Write role. If not: Add Repository → secure-cicd-go → Write. That's what lets GITHUB_TOKEN in Part 8 push to this package.

Check: pull without logging in, the way Dokploy does.

bash
docker pull --platform linux/amd64 "$IMG:development"

It should work even after docker logout.

Is a public image safe? Yes, because the image contains no secrets. Secrets only arrive when the container runs, from Vault through the sidecar. It's also a test of the design: if a secret were baked into the image, making it public would leak it, and that's exactly what Gitleaks and Trivy guard against. In a company the registry would usually be private, with a dedicated read-only pull credential on the server. Here I went public because it's free and simple.

First dev deploy and testing

Dokploy → secure-cicd-go-dev → Deploy. Open the Deployments tab and click the top deployment to see its log:

text
Command: docker compose -p secure-cicd-go-dev-xxxxxx --env-file ... -f ./docker-compose.development.yml up -d --build --remove-orphans... Container secure-cicd-go-dev-xxxxxx-vault-agent-1  Healthy Container secure-cicd-go-dev-xxxxxx-app-1  Started

Healthy then Started is depends_on: condition: service_healthy at work: the app only starts after config.json has been rendered.

In the Logs tab, check each container:

Container

Line to look for

Meaning

vault-agent

authentication successful

AppRole login to the real Vault worked

vault-agent

rendered "/vault/config/config.json.ctmpl" => "/vault/out/config.json"

Template rendered to tmpfs

app

{"level":"INFO","msg":"listening","addr":":8080","env":"development"}

App read its config and runs as dev

Also make sure there are no secret values in any log. Vault Agent doesn't print the template contents, and the app only prints env.

Test /health and /version

bash
D=https://secure-cicd-dev.yourdomain.comcurl -s $D/healthcurl -s $D/versionecho "should match: $COMMIT"
text
{"status":"ok"}{"buildTime":"2026-10-31T..Z","commit":"<full SHA>"}should match: <the same full SHA>

This /version check becomes the smoke test in Part 8: a deploy only counts when the running commit is the commit that was scanned.

Then test that the secrets from Vault are really in use. JWT:

bash
TOKEN=$(curl -s -X POST $D/v1/tokens -d '{"subject":"demo-user"}' | jq -r .token)curl -s $D/v1/me -H "Authorization: Bearer $TOKEN"   # → {"subject":"demo-user"}curl -s $D/v1/me -H "Authorization: Bearer wrong"    # → {"error":"invalid token"}

And AES:

bash
CT=$(curl -s -X POST $D/v1/encrypt -d '{"plaintext":"hello fictional bank"}' | jq -r .ciphertext)curl -s -X POST $D/v1/decrypt -d "{\"ciphertext\":\"$CT\"}"   # → {"plaintext":"hello fictional bank"}

The demo token is fake and short-lived, so it's fine to see it in the terminal.

Check the HTTPS certificate

bash
echo | openssl s_client -connect secure-cicd-dev.yourdomain.com:443 \  -servername secure-cicd-dev.yourdomain.com 2>/dev/null \  | openssl x509 -noout -issuer -subject -dates

-servername sends SNI so Traefik knows which domain you're asking for (one IP, many domains). Check: issuer mentions Let's Encrypt, subject is your dev domain, and the validity is about 90 days. If issuer says TRAEFIK DEFAULT CERT, see the troubleshooting table below.

The production service: configure only, don't Deploy

Repeat the service, Environment, and domain steps in Dokploy's production environment, with these values:

Field

Value

Name

secure-cicd-go-prod

Provider

Git, https://github.com/dimss113/secure-cicd-go.git

Branch

main

Compose Path

./docker-compose.production.yml

Autodeploy

OFF

Environment

VAULT_ADDR=https://vault.example.com, production role credentials

Domain

Not possible yet, added in Part 8

For the credentials, switch the role variable first, then repeat revoke-old, pbcopy the role_id, pbcopy the secret_id, clear the clipboard, and check for exactly one accessor:

bash
ROLE=secure-cicd-go-production

Something that tripped me up: the Domains tab on the prod service wouldn't let me pick app. The list was empty. It turns out Dokploy reads the service list from the compose file on the selected branch, and main at that point only had the initial commit, no docker-compose.production.yml. That's fine, because the domain only matters at deploy time. The prod domain gets added in Part 8, after the development to main PR is merged and before approving the production deploy.

Don't press Deploy. There's no :production image yet, and there must not be one before Part 8.

Why doesn't production get a bootstrap image like dev? Because the rule is: production only runs an image that CI built, that already ran in dev, and that was approved. A hand-built laptop image must never reach production, scanned or not. For dev, the bootstrap risk is small and short-lived. For production, the principle matters more than seeing it run sooner. Until Part 8, the production domain answers with Traefik's 404 page not found. That's normal.

One more thing to note down for Part 8: each service's composeId. You'll find it in the URL of the service page in Dokploy (the last segment after /services/compose/). A composeId isn't secret, it's just an ID that's useless without an API key, so it goes into a GitHub Environment variable, not a secret.

Note

No Dokploy API key yet

My original plan created the Dokploy API key at this stage. I moved it to Part 8, right before it's pasted where it's used. A secret should be created when it's needed and go straight to its destination, not be created early and "parked" somewhere. The Dokploy API key works for the whole account, so the shorter its life outside Dokploy, the better.

Runbook practice: rotating a secret

This makes a good interview story: how do you rotate an app secret without touching code?

text
Vault (new secret)   │  Vault Agent re-fetches static KV v2 secrets every ~5 minutes (default)   ▼config.json on tmpfs (already new)   │  the app reads its config ONLY at start   ▼app (still using the old secret in memory)  ← needs a restart

And we already know a Deploy with no changes doesn't restart anything. So the move is Stop, then Start. On Stop, the tmpfs is emptied too (I tested this with a local compose stack), so on Start vault-agent logs in again and renders the latest secret immediately, without waiting 5 minutes.

Try it on dev. Create a token with the old secret:

bash
OLD=$(curl -s -X POST $D/v1/tokens -d '{"subject":"before-rotation"}' | jq -r .token)curl -s $D/v1/me -H "Authorization: Bearer $OLD"   # → {"subject":"before-rotation"}

Rotate the dev JWT secret in Vault. $(openssl rand -hex 32) generates a random value, and shell history keeps only the command text, not the value:

bash
vault kv put -mount=static-secret secure-cicd-go/development/secrets/jwt secret_key="$(openssl rand -hex 32)"

Check again: the old token is still accepted, because the app hasn't restarted. Now Dokploy → secure-cicd-go-dev → Stop, wait, Start.

bash
curl -s $D/v1/me -H "Authorization: Bearer $OLD"NEW=$(curl -s -X POST $D/v1/tokens -d '{"subject":"after-rotation"}' | jq -r .token)curl -s $D/v1/me -H "Authorization: Bearer $NEW"
text
{"error":"invalid token"}{"subject":"after-rotation"}

Rotation done, with no change to code or Dokploy env. The trade-offs are worth writing down honestly: Stop/Start means a few seconds of downtime, rotating the JWT secret logs every user out (real systems use two keys during the transition, for example JWKS with kid), and rotating the AES key is more dangerous because old data can't be decrypted without re-encryption or key versioning like Vault Transit.

When something goes wrong

Symptom

Cause

Fix

required variable VAULT_ADDR is missing a value: set VAULT_ADDR

Environment tab not saved, or Create Env File OFF

Save, turn on Create Env File, Deploy again

manifest unknown when pulling app

:development not pushed, or wrong tag

Redo the bootstrap, check docker buildx imagetools inspect $IMG:development

denied / unauthorized on pull

GHCR package still private

Change visibility → Public

app keeps restarting, exec format error

arm64 image, forgot --platform linux/amd64

Rebuild with the right platform, push

vault-agent unhealthy, invalid role or secret ID

Wrong role_id/secret_id pasted, or already revoked

New secret_id, Save, Stop then Start

vault-agent no such host for Vault

DNS inside the container. On my VPS it once resolved to 127.0.x.x because of /etc/hosts

docker exec <vault-agent> getent hosts vault.example.com; extra_hosts workaround via PR

Browser: TRAEFIK DEFAULT CERT

Let's Encrypt failed: DNS wrong at deploy time, or port 80 closed

Check dig, wait, Deploy again. Read the dokploy-traefik container log

404 page not found (plain text)

Domain not live yet, or wrong Service Name

Check Domains → app, then Deploy

502 Bad Gateway

Port isn't 8080, or the app is crashing

Check Container Port and the app log

Changed env, nothing happened

A Deploy with no changes doesn't restart containers

When in doubt, Stop then Start

Recap

  • A secure-cicd-go project in Dokploy with two Compose services from the plain Git provider, with no access to the GitHub account.

  • Auto-deploy off on both, so CI is the only path to a deploy.

  • Separate AppRole credentials per environment, pasted via the clipboard without ever showing on screen, exactly one active secret_id per role.

  • A dev domain with Let's Encrypt HTTPS through Traefik, routed only to app:8080.

  • A bootstrap :development image from the laptop, linux/amd64, passing Trivy HIGH/CRITICAL, in a public GHCR package with Write access for the repo's Actions.

  • Dev is running: /health is ok, /version matches the commit, and JWT and AES from the real Vault are proven in use.

  • Production is configured but not deployed, plus a secret rotation runbook that's been tried.

In Part 8 we write deploy.yml: build the image once in CI, scan it, generate an SBOM, push to GHCR, call the Dokploy API to deploy dev, and promote that same image to production after an approval. That's also where the Dokploy API key finally gets created, and the bootstrap image retires.

Related articles