The problem

Back to our online shop: a monorepo driven by moon, Symfony services order, stock, shipping and billing, and Vue.js fronts. Each service has its container image. Two environments, staging then production, run them with Dokploy on Docker Swarm.

A fix to the order service is approved in staging. To ship it to production, Dokploy starts again from the same commit and rebuilds the image, as it does with a Git source. In the meantime, the base image received an update and a dependency published a patch.

The production image is therefore not the one that was tested, and the vulnerability scan covered the staging image. Nobody can say for sure any more which code runs in production.

Tags that move. The image is pushed as order:staging, then retagged order:production at promotion time. Dokploy hands this tag as is to Swarm, and each node resolves it when it starts a task. Nodes may then run different versions.

A blurry rollback. Going back to the previous version requires knowing which image it used. With a reused tag, that information is gone.

A CI that reaches into production. To deploy, the pipeline calls the API of each environment's Dokploy, with its key. If your environments are isolated from the CI network, that isolation has to be pierced.

The options

Rebuild in each environment

  • What it solves: each Dokploy builds from Git on its own, with no pipeline to write.
  • What it costs: the deployed image is not the tested image. Each build has to be scanned again, and the isolated zone has to download the dependencies.

One image, one tag per environment

  • What it solves: nothing is rebuilt any more. Promoting means moving the production tag onto the approved image.
  • What it costs: a tag is a mutable pointer. What runs depends on when each node resolved it, and Dokploy only shows the tag.

One image by digest, deployed from the CI

  • What it solves: the image is identified by its content, hence immutable. The CI records the approved digest through the Dokploy API, then starts the deployment.
  • What it costs: the forge holds an API key per environment and must be able to reach each Dokploy. A compromise of the CI then reaches production directly.

One image by digest, pulled from inside the environment

  • What it solves: the same immutable image everywhere. In each environment, a job reads the digest to deploy and calls its local Dokploy.
  • What it costs: a small component to write and monitor per environment, and a delay before each deployment.

Our recommendation

One image per commit, built and scanned once, deployed everywhere by its digest. Each environment has its own Dokploy, and a job placed inside the environment hands it the digest to promote.

A digest is the cryptographic fingerprint of the image's content. A tag can be moved to another image; a digest always designates the same content. Dokploy accepts it in the image field of an application with a Docker source: registry.example.com/shop/order@sha256:....

Dokploy pulls this reference and passes it unchanged to the Swarm service. Each node then starts exactly this image.

This is the "build, release, run" separation of The Twelve-Factor App. Here, the release is the pair "image digest + Dokploy variables of the environment".

Promoting therefore means changing the digest deployed in an environment. Never rebuilding.

One image built once, deployed by digest in each environment Connected zone: builds and scans once Forge and CI build and scan, once per commit push, once Image registry shop/order@sha256:c4993c... pulls the image by digest pulls the image by digest reads the digest reads the digest no connection into the environments Isolated environments Staging zone Swarm nodes: order@sha256:c4993c... drives Docker Dokploy local API promotion job Production zone Swarm nodes: order@sha256:c4993c... drives Docker Dokploy local API promotion job Arrows start from whoever opens the connection: never from the connected zone into an environment.

Two zones, connections in one direction only

The connected zone holds the forge, the CI and the image registry (Nexus or Harbor, for instance). The CI builds, scans and pushes the image there once per commit, with Internet access for its dependencies.

Dokploy drives Docker on its own server, and opens an SSH connection to each remote server it manages. Placed in the connected zone, a single Dokploy would therefore have to reach every environment: each environment needs its own.

Every connection between the zones starts from the environment. The servers pull the image from the registry, the job reads the digest from the forge. The forge holds no Dokploy API key.

Who triggers the deployment?

Dokploy deploys when its API (application.deploy) or a webhook URL is called. From the CI, these are inbound calls, which require a network path into the environment. The webhook, moreover, redeploys the image already configured: it does not carry a new digest.

The trigger therefore starts inside the environment, in one of these forms:

  • an operator runs the promotion script on an approved file. No automation reaches the forge, but each promotion waits for a person;
  • a runner from your forge, installed in the environment, calls the local API. It connects to the forge itself, and the forge then decides which commands run in the zone;
  • a promotion job regularly compares the wanted digest, read from a versioned file, with the last deployment recorded by Dokploy. When they differ, it calls the local API.

We recommend the job: the forge only gives it an image reference, which it checks before applying it. The operator uses the same script when the zone cannot reach the forge.

Configuration stays in the environment

The image contains no environment-specific value. What varies lives in the environment's Dokploy: its variables, defined per project, environment or service, and the settings of each application. Dokploy keeps these variables in its database, encrypted since version 0.29.12.

Secrets stay out of the forge as well: another article of this series covers them.

The trade-offs we accept

The CI and the registry become points of trust. An image accepted by the registry can end up in production. We therefore restrict registry writes to main-branch builds and monitor pushes. When the registry allows it, tags become immutable (Harbor offers tag immutability rules).

Image signatures and SBOMs, verified before deployment, strengthen this control: a dedicated article of the series covers them.

The forge decides what gets deployed. A pull request merged on a promotion file is enough to deploy: branch protection rules guard the way into production. The job rejects any reference that does not designate, by its digest, an image of the expected registry.

One Dokploy and one job per environment. They have to be installed, kept up to date and monitored, and the Dokploy API must only be reachable from inside the zone. The job's key belongs to a member limited to the shop's services, with "Create Services", which application.update requires. It reads their secrets and can mount a host path: it is root access.

Configuration has to leave the image. A Vue.js front built with Vite freezes its import.meta.env variables at build time. For a single image, it reads its configuration at startup, from a file served by its container.

Deployment is not instantaneous. It waits for the job's next run, set by the chosen interval.

The signal that should make us reconsider: a requirement to build the images inside the isolated zone itself. The "same image everywhere" guarantee then disappears, and each environment build has to be traced and scanned separately.

Implementation

The examples use Dokploy 0.30.8, the latest version as of 2 October 2026, and Docker Buildx. The code of each service lives in services/<service>/, the promotion files in deploy/<environment>/.

1. Build once and record the digest

The CI builds the service image, pushes it and reads its digest from the Buildx metadata file. The tag carries the commit SHA, for humans; the deployment will only use the digest.

# ci/build-image.sh (run by the docker-build task of each service)
set -euo pipefail

SERVICE="$1" # order, stock, shipping, billing
IMAGE="registry.example.com/shop/${SERVICE}"
COMMIT_SHA="$(git rev-parse HEAD)"

docker buildx build \
  --file "services/${SERVICE}/Dockerfile" \
  --tag "${IMAGE}:${COMMIT_SHA}" \
  --metadata-file build-metadata.json \
  --push \
  .

DIGEST="$(jq -er '."containerimage.digest"' build-metadata.json)"
echo "Image built: ${IMAGE}@${DIGEST}"

The docker-build task of each affected service runs this script, as described in the article on CI pipelines driven by moon. The scan then targets ${IMAGE}@${DIGEST}: its result holds for the exact image that will be deployed.

2. One Dokploy application per service, with a Docker source

In the Dokploy of each environment, the shop project holds one application per service, with a Docker source. The "Docker Image" field receives the reference by digest, the registry fields a read-only account.

On each deployment, Dokploy pulls the image with this account and passes it on to the Swarm service.

By default, Dokploy 0.30.8 starts the new container before stopping the old one (start-first), and Swarm rolls back if the update fails. Without a health check, a container counts as ready as soon as it starts. So declare one in the Swarm Settings of each application.

3. One promotion file per environment

Each environment has a versioned file that gives the image to deploy for each service. It is the desired state, which the job compares with Dokploy.

# deploy/staging/images.yaml
order: registry.example.com/shop/order@sha256:c4993c19adba79d7f2918ab9070d0791f4dfbef5ae57add954ece0e9a060dc25

After each main-branch build that passes the scan, the CI writes the new reference into the staging file, with yq for instance. It then opens a pull request.

4. The promotion job, inside the environment

The job runs inside the environment at a regular interval, started by a systemd timer. It updates a read-only clone of the repository, then runs this script for each service. The script is installed in the zone, never read from the clone.

# promote.sh <service> <applicationId> (installed inside the environment)
set -euo pipefail

SERVICE="$1"        # order
APPLICATION_ID="$2" # identifier of the application in this Dokploy
# DOKPLOY_URL, DOKPLOY_API_KEY, ENVIRONMENT and CLONE are defined in the zone.

WANTED="$(yq -er ".${SERVICE}" "${CLONE}/deploy/${ENVIRONMENT}/images.yaml")"
if [[ ! "${WANTED}" =~ ^registry\.example\.com/shop/${SERVICE}@sha256:[0-9a-f]{64}$ ]]; then
  echo "Reference rejected: ${WANTED}" >&2
  exit 1
fi

api() {
  curl -fsS --max-time 30 -H "x-api-key: ${DOKPLOY_API_KEY}" -H 'Content-Type: application/json' "$@"
}

APP="$(api -G "${DOKPLOY_URL}/api/application.one" --data-urlencode "applicationId=${APPLICATION_ID}")"
LAST="$(jq -r '.deployments | max_by(.createdAt) // {} | "\(.description) \(.status)"' <<<"${APP}")"
case "${LAST}" in "${WANTED} done" | "${WANTED} running") exit 0 ;; esac

api "${DOKPLOY_URL}/api/application.update" \
  -d "$(jq -n --arg id "${APPLICATION_ID}" --arg image "${WANTED}" '{applicationId: $id, dockerImage: $image}')"
api "${DOKPLOY_URL}/api/application.deploy" \
  -d "$(jq -n --arg id "${APPLICATION_ID}" --arg image "${WANTED}" '{applicationId: $id, title: "Promotion", description: $image}')"

application.update only changes the fields it receives, whereas application.saveDockerProvider also rewrites the registry credentials. The identifier of an application can be read in its URL.

application.deploy queues the deployment and returns. Self-hosted, this queue lives in memory: a Dokploy restart empties it. The job therefore compares the wanted digest with the last deployment, whose description carries it: a failure is retried on the next run.

5. Promote to production: copy a digest

The promotion is a pull request that copies the digest approved in staging into the production file. Nothing is rebuilt.

 # deploy/production/images.yaml
-order: registry.example.com/shop/order@sha256:1c993ee80d9608972cf94d35385fcf249bb408180adfdff2248fe89a8b5b32d8
+order: registry.example.com/shop/order@sha256:c4993c19adba79d7f2918ab9070d0791f4dfbef5ae57add954ece0e9a060dc25

Branch protection rules decide who can approve this change, and no token, the CI's included, bypasses them. Once the pull request is merged, the production job deploys on its next run.

Rolling back follows the same path: a revert of the promotion commit restores the previous digest. Dokploy's registry-based rollbacks are not needed.

Dokploy marks a deployment as done as soon as Swarm accepts the update. If Swarm then rolls back, Dokploy still shows the new digest: docker service inspect, on a manager, gives the image actually running.

6. If the servers cannot leave the zone

Some isolated zones forbid their servers any outbound connection. A registry placed inside the zone, the only one allowed out, then fetches the images: pull-mode replication in Harbor, a proxy repository in Nexus. The Git repository follows the same path, through a mirror.

The job reads this mirror, validates the reference, then replaces the registry name while keeping the digest. The images that run Dokploy (Dokploy, PostgreSQL, Traefik) also go through this registry.

The promotion is still a digest: check that the copy preserves it. A format or compression conversion produces different content, hence a different digest.

By default, Buildx pushes an index that bundles the image and its provenance attestation. For a manual copy, skopeo copy --all --preserve-digests copies this whole index and fails rather than change a digest.

Checklist

  • One image per service and per commit, built only once and scanned by its digest.
  • Dokploy applications with a Docker source, and an image designated by digest.
  • One promotion file per environment, protected by branch rules.
  • No environment configuration in the images, fronts included.
  • One Dokploy per isolated environment, its API reachable from inside the zone only.
  • A promotion job installed in the zone, which rejects any reference without a digest.
  • No Dokploy API key in the forge, no connection from the CI into the environments.
  • A health check on each application, and the running image checked after each deployment.
  • Registry writes restricted to the CI, digests checked after any copy into the zone.

Sources

Versions checked on 2 October 2026: Dokploy 0.30.8 and Docker Engine 29.8.2.