The problem
Take our online shop again. The Symfony services
order, stock,
shipping and billing run in two
environments, staging then
production. Each one has its own Dokploy, which
drives Docker Swarm on its servers.
The CI builds one image per commit. Inside each environment, a promotion job hands the digest to deploy to the local Dokploy API. We described this model in the "build once, promote many" article: it leaves one question open, where do the secrets live?
Here, they are the database password of order,
the RabbitMQ credentials, the payment key and the
Mercure tokens.
The usual reflex is to store them in the Git forge: GitHub Actions secrets, GitLab CI/CD variables, Azure DevOps variable groups. The pipeline then writes them into each Dokploy, through its API. This choice has several effects.
The forge is seen by more people than production. Developers, runners, integrations and third-party actions all reach it. According to GitHub's documentation, anyone who can write to a repository has read access to all its secrets.
GitLab and Azure DevOps describe the same risk: code pushed into the pipeline definition can extract the variables, even masked ones. On a self-hosted GitLab instance, anyone with access to the database and to the instance's secrets file can decrypt these variables.
The secret leaves the forge through the pipeline. Inside the job, the value is available to every script it runs. GitHub warns that log masking is not guaranteed, for instance for a value wrapped in JSON.
The CI holds a key to every Dokploy. To write a variable, its key must have access to the service: so it also reads its other variables. The promotion job lives inside the environment so that no connection leaves the connected zone: that path would have to be opened again.
Storing the secrets in Dokploy also takes precautions. Before version 0.29.12, released on 13 July 2026, Dokploy kept the variables in plain text in its database. Whatever the version, each value ends up in plain text in the Swarm service spec.
The options
Forge secrets, pushed by the pipeline
- What it solves: a single tool, a ready-made interface.
- What it costs: everything above. The CI becomes a path to every environment, and the readers of a production secret are the repository's contributors.
Dokploy variables, entered in each environment
Dokploy stores variables at three levels: project,
environment and service. Only the service's variables reach
the container; they refer to the others with
${{project.NAME}} or
${{environment.NAME}}. Since version 0.29.12,
Dokploy encrypts them in its database with AES-256-GCM.
- What it solves: nothing to install, and the value never leaves the zone.
- What it costs: no history and no read log. The encryption key derives from a Docker secret of the same Swarm as the database. Anyone who opens the service in Dokploy reads its values.
The Symfony vault, with only the decryption key in Dokploy
secrets:set encrypts each value with the
vault's public key, and the encrypted files are versioned.
Dokploy only holds SYMFONY_DECRYPTION_SECRET,
the private key encoded in base64. With a single image, the
staging and production vaults
travel inside the image, and
APP_RUNTIME_ENV picks the right one.
- What it solves: versioned secrets, reviewed in pull requests, and a single value to protect per environment.
- What it costs: the forge keeps the encrypted values forever, and a leaked key opens the whole history. Changing a secret takes a commit, a build and a promotion: rotation follows the release pace.
A vault inside the zone, referenced by Dokploy
Since version 0.30.0, released on 14 August 2026, Dokploy
can read an external secrets manager. A
${{vault.<provider>.<reference>}}
variable is resolved at deploy time, for instance from
OpenBao or Vault (KV v2 engine). The value is not stored in
Dokploy's database.
- What it solves: one source of truth per environment, outside Dokploy's database, backups and API. The vault logs every read made with Dokploy's token.
- What it costs: one more critical service, which needs tested backups and a recovery procedure, as OWASP points out. Dokploy authenticates with a static token, kept in plain text in its database and never renewed.
Docker secrets (/run/secrets) are not offered
for a Dokploy application, only in a Compose file deployed
as a stack.
Our recommendation
Production secrets live in the Dokploy of the environment that consumes them. The forge only carries non-secret parameters, and the CI holds neither a production secret nor a Dokploy key.
By default, we recommend entering them as service variables in each Dokploy, and hardening what surrounds them: accounts, key, backups. An OpenBao vault placed inside the zone takes over on the signal described below. If the project uses the Symfony vault, we keep it for development: environment variables take precedence over its values.
Why not a vault right away? The resolved value ends up in the Swarm service, like a Dokploy variable.
According to Dokploy's documentation, anyone who edits the variables and deploys in an assigned project reads everything the token reaches. That includes other services' secrets. The vault adds a log of Dokploy's reads, not fewer readers.
The forge keeps the code, the promotion files and the names of the expected variables.
Each environment has its own set of secrets:
-
its Dokploy and its accounts: developers log into the
stagingone, not theproductionone; -
different values: the
stagingpayment key is a test key from the provider; -
no production value copied into
staging, not even for debugging.
The trade-offs we accept
Access to a service means access to its secrets.
Anyone with access to billing in Dokploy reads
its variables: Environment tab,
application.one API, container terminal. A
custom role (Enterprise licence) changes nothing: in version
0.30.8, the API and the terminal only check access to the
service.
Swarm nodes are part of the perimeter.
docker service inspect on a manager, like
docker inspect on the task's node, shows every
variable in plain text. The docker group grants
root-equivalent rights, and so does the promotion job's key,
which can mount a host path.
No read log. Dokploy does not record who reads a variable. Its audit logs, reserved for the Enterprise licence, tell who changed a service's variables, and when.
Two delivery paths. The code goes through the CI and the promotion job, the secrets through an entry inside the zone. A new secret variable needs a step outside the pipeline, which the promotion job checks.
The signal that should make us revisit this choice: an audit that requires a log of every secret's reads. Secrets read outside Dokploy also call for a vault inside the zone. Conversely, a vault that nobody really operates protects less than well-kept Dokploy variables.
Implementation
The examples use Dokploy 0.30.8, the latest version on 3
October 2026, and Symfony 7.4. The Dokploy application of
billing is called shop-billing,
like its Swarm service.
1. Inventory the secrets
| Secret | Services | Rotation |
|---|---|---|
DATABASE_PASSWORD |
order |
scheduled, then a reload of the service |
MESSENGER_TRANSPORT_DSN |
order, stock,
shipping, billing
|
new RabbitMQ user, reload, then delete the old one |
PAYMENT_API_KEY |
billing |
new key at the provider, then revocation |
MERCURE_PUBLISHER_TOKEN |
order, stock,
shipping
|
new token signed in the zone before it expires |
MERCURE_SUBSCRIBER_SECRET |
order, Mercure hub |
changed in both at once |
The zone also has its own secrets: the promotion job's API key, Dokploy's authentication secret and the private key that signs the Mercure tokens.
2. Harden each Dokploy
Dokploy derives its encryption key from its authentication
secret, or from a dedicated key
(ENCRYPTION_KEY_FILE). An installation older
than version 0.29.3 may still use a secret hard-coded in
Dokploy: the key is then public.
# On a manager: the authentication secret must come from a Docker secret.
docker service inspect dokploy \
--format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' \
| grep -oE '^(BETTER_AUTH_SECRET|ENCRYPTION_KEY)(_FILE)?='
The output should show a
BETTER_AUTH_SECRET_FILE= line. Without it,
Dokploy's startup logs give the migration command to run.
In version 0.30.8, changing this secret makes the already
encrypted variables unreadable, and a deployment then goes
out without them (issue #4833). Before migrating, add the old derived key to the
/etc/dokploy/encryption.key file, which Dokploy
tries as a last resort. Then:
- every variable saved again after the migration, then that file removed: until then, a value stays in plain text or under the old key;
- no secret in a file mounted by Dokploy ("File Mount"): the database does not encrypt it, nor a Dokploy-managed database's password;
- no registry rollback: each snapshot keeps the variables in plain text in the database;
- backups kept inside the zone, without the "Include encryption key" option, checked by default: the key is kept separately, offline.
3. Store each secret in its service
Shared parameters, such as MERCURE_URL, go at
the environment level; secrets, at the level of the service
that consumes them. A service only receives its own
variables, plus those it refers to with
${{environment.NAME}}.
Secret values are entered inside the zone. The forge only
knows their names, in
services/billing/env.keys. The promotion job
checks them once the digest is found to be new, before
application.update:
# Excerpt of promote.sh, before application.update: APP holds the response of application.one.
while IFS= read -r NAME || [[ -n "${NAME}" ]]; do
jq -e --arg name "${NAME}" '(.env // "") | split("\n") | any(startswith($name + "=") and length > ($name | length) + 1)' \
<<<"${APP}" >/dev/null || { echo "Missing or empty variable: ${NAME}" >&2; exit 1; }
done <"${CLONE}/services/${SERVICE}/env.keys"
4. Read the secret in Symfony
For Symfony, the secret is an environment variable like any other:
# config/services.yaml (billing service)
services:
App\Payment\PaymentClient:
arguments:
$apiKey: '%env(PAYMENT_API_KEY)%'
The constructor parameter carries
#[\SensitiveParameter], which hides it in the
trace of that call.
5. Rotate a secret
- Create a new key at the provider, without revoking the old one.
-
Enter it in the variables of
billing, in the production Dokploy. - Click "Reload": a variable is only read when the container starts.
- Check the service, then the payments.
- Revoke the old key.
"Reload" (application.reload API) updates the
Swarm service without creating a deployment: the promotion
job does not redeploy anything.
Swarm starts the new task before stopping the old one: both keys are in use for a moment. If the update fails, it reverts to the previous spec, old key included. Hence this check before revoking:
# On a manager: "completed", not "rollback_completed".
docker service inspect shop-billing --format '{{.UpdateStatus.State}}'
docker service ps shop-billing --filter desired-state=running
If the Messenger workers of billing form
another Dokploy application, the rotation also updates and
reloads their variables.
6. Trace access
Logins to the Swarm nodes and membership of the
docker group are logged and reviewed.
When an audit requires a read log, we recommend moving to the vault inside the zone: the variable only holds a reference.
# Production Dokploy, billing service: a reference, not a value.
PAYMENT_API_KEY=${{vault.prod-openbao.shop/billing:payment_api_key}}
# policies/dokploy-shop.hcl (production vault): read only.
path "secret/data/shop/*" {
capabilities = ["read"]
}
No audit device is enabled by default. Once one is, OpenBao records every read, with hashed values, and refuses any request that none of them can record.
This log names Dokploy's token, not a person. Dokploy's
audit logs (Enterprise) tell who deployed; the terminal and
docker service inspect escape both.
With OpenBao's default configuration, Dokploy's token expires after 32 days at most, and Dokploy does not renew it. It therefore joins the rotation inventory.
Checklist
- No production secret in the forge's secrets or variables.
- No Dokploy API key in the forge, no connection from the CI to the environments.
- One Dokploy per environment, and no developer account on the production one.
- Dokploy 0.29.12 or later, its authentication secret in a Docker secret, migrated without losing the old key.
-
Secrets at the service level, with different values in
staging. - The names of the expected variables in the forge, checked by the promotion job.
- No secret in a mounted file, no registry rollback, backups without their key.
- A tested rotation procedure for each secret, reload included.
- Logged access to the nodes, and a vault as soon as an audit requires a read log.
Sources
Versions checked on 3 October 2026: Dokploy 0.30.8, Docker Engine 29.8.2 and OpenBao 2.7.1.
- GitHub, Secure use reference and Using secrets in GitHub Actions.
- GitLab, CI/CD variables.
- Microsoft, Variable groups and Secret variables.
- OWASP, Secrets Management Cheat Sheet.
- Dokploy, versions 0.29.12 (variable encryption, PR #4789), 0.30.0 (secrets providers) and 0.30.8, issue #4833 (changing the authentication secret).
- Dokploy, Environment Variables, Secrets Providers, HashiCorp Vault / OpenBao, Permissions, Audit Logs and Backups.
- Dokploy, code of version 0.30.8: encryption, Swarm service variables, variable resolution, applications API and terminal access.
- Dokploy, code of version 0.30.8: secrets providers, rollbacks, mounted files and backup.
- Docker, Manage sensitive data with Docker secrets, Docker daemon attack surface and Linux post-installation steps.
- Docker, docker service inspect, docker service update, docker service ps and update states.
- OpenBao, audit, tokens and project site.
- Symfony, secrets.