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
productiontag 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.
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.
- The Twelve-Factor App, Build, release, run: separation between build, release and run.
- OCI, Distribution Specification: digest and tag.
- Docker, Deploy services to a swarm (CLI behaviour) and Engine code: API version, service creation, image pulls.
- Docker, docker buildx build and Attestation storage.
- Dokploy, versions 0.30.8 and 0.29.12 (encrypted variables).
- Dokploy, Docker Registry, Going Production and Rollbacks.
- Dokploy, Auto Deploy, API and Environment Variables.
- Dokploy, Deployment Options and Permissions.
- Dokploy, code of version 0.30.8: Swarm service, default update, API and webhook.
- GitHub, Self-hosted runners reference.
- Harbor, tag immutability and replication; Sonatype, Nexus Repository, Docker.
- skopeo copy, cosign, Vite Env Variables and Modes and yq documentation.