Doppler service tokens in CI pipelines
A CI pipeline needs secrets to run integration tests and deploys, but it should never hold long-lived credentials or see more than its own environment. A scoped Doppler service token solves both. This page wires it into CI, extending Doppler for Multi-Cloud Secrets.
CI deserves its own treatment because it is the environment with the widest access and the weakest boundaries. A pipeline is a program that many people can modify, whose output many people can read, and which by design runs on shared infrastructure — so a token that grants more than that pipeline needs is a token effectively exposed to everyone who can open a pull request against the repository, whether or not anyone intends to use it that way.
Problem 1: production secrets in the CI config
# ANTI-PATTERN: a broad token (or raw secrets) pasted into CI variables
env:
DOPPLER_TOKEN: dp.st.prod.broadAccessToken # grants prod to every CI job
A production-scoped token in CI hands every pipeline run access to production secrets. The inline value makes it worse — it is committed, so it is in history, in every fork, and in every clone, and rotating it means rotating something that has already been distributed.
The reasoning that leads here is usually practical rather than careless: the deploy job needs production access, so someone gives the pipeline a production token, and the test jobs inherit it because variables are defined at the workflow level. The fix is not to remove the deploy job’s access but to separate the two — a ci token for tests, a production token available only to the deploy job, scoped by the platform’s environment mechanism.
Problem 2: echoing fetched secrets
# ANTI-PATTERN: secrets printed into the build log
doppler secrets download --no-file --format env # then echoed by a later step
Downloading then printing leaks the values into the build log. This rarely happens deliberately; it happens because set -x was enabled for debugging, or a step ran env to diagnose something unrelated, or a test framework printed its configuration on failure. All three are reasonable actions that become disclosures when secrets are in the environment as plain strings.
CI platforms mask known secret values in log output, and that helps with the simple case and not the transformed one. A value that is base64-encoded, embedded in JSON, or split across lines no longer matches the masked string and appears in full. Masking is a safety net, not the primary control — the primary control is not putting the values anywhere a debugging command will find them.
Secure implementation
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -Ls https://cli.doppler.com/install.sh | sh
- run: doppler run --config ci -- pytest # injects CI-scoped secrets only
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_CI_TOKEN }} # masked, read-only, ci config
# tests/conftest.py — read injected values, never print them
import os
from pydantic import SecretStr
API_KEY = SecretStr(os.environ["API_KEY"]) # masked in repr; unwrap only where needed
The DOPPLER_CI_TOKEN is a read-only token scoped to the ci config, stored as a masked CI secret. doppler run injects the values into the test process; nothing is downloaded to a file or printed.
Two placements in that workflow carry most of the safety. The env: block sits on the individual step, not the job, so the checkout and the CLI install run without the token in their environment — which matters because the install pipes a script from the internet into a shell, and that script should not have access to anything.
And doppler run --config ci -- pytest scopes the values to the pytest process only. They exist for the duration of that command, in that process tree, and nowhere else in the job. A subsequent step that runs env sees nothing, because the injection ended when pytest exited.
Wrapping in SecretStr inside conftest.py is the last layer. A test that fails and dumps its fixtures prints a mask rather than a credential — which is exactly the situation where a plain string would otherwise end up in a log that stays around for months.
Rotating the CI token without breaking the pipeline
The CI token is a credential like any other and needs replacing on a schedule. It is also unusually easy to rotate, because it authorises access rather than being a credential to a downstream system — and because Doppler allows several tokens against the same config, an overlap is available for free.
# 1. issue a new token for the same config; both are now valid
doppler configs tokens create ci-github-2026q3 --config ci --plain
# 2. update the CI secret (DOPPLER_CI_TOKEN) to the new value
# 3. confirm a pipeline run succeeds with the new token, then revoke the old one
doppler configs tokens revoke --config ci --slug <old-token-slug>
That is provision-verify-revoke, the same ordering as any other rotation, and it takes minutes because nothing downstream is involved. Naming tokens by consumer and quarter — ci-github-2026q3 — makes the token list self-documenting, so a token nobody can account for is obvious rather than something you leave in place because you are not sure who uses it. An unnamed token in a list of a dozen is the one that never gets revoked, and it is usually the oldest one there.
The one thing to check before revoking is whether the old token is used anywhere else. A token created for CI that someone also pasted into a local script or a second repository will break when revoked, and the failure appears somewhere unrelated. Doppler’s activity log shows recent uses per token, which answers that in a glance and is worth consulting rather than assuming — a revocation that breaks an unrelated job three days later is a confusing outage nobody connects back to a token rotation.
Scheduling the rotation matters more than its frequency. A quarterly reminder that actually fires beats a monthly policy that nobody performs, and since the whole operation is three commands and one pipeline run, quarterly is comfortably sustainable.
Separating the deploy job from the test jobs
The workflow above covers tests. Deploys need production values, and the temptation is to give the whole pipeline a production token so the deploy step works. Keeping the two apart is a matter of job structure rather than cleverness.
# .github/workflows/deploy.yml — two tokens, two scopes, one dependency edge
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: doppler run --config ci -- pytest
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_CI_TOKEN }} # ci config only
deploy:
needs: test # never deploys unless tests passed
runs-on: ubuntu-latest
environment: production # production secrets live here, not repo-wide
steps:
- uses: actions/checkout@v4
- run: doppler run --config prd -- ./deploy.sh
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_PRD_TOKEN }} # prd config only
The environment: production key is what confines the production token to this job. Defined as an environment secret rather than a repository secret, DOPPLER_PRD_TOKEN is simply not resolvable from the test job — it evaluates to empty, so even a modified test step cannot read it. That is enforcement by the platform rather than by convention.
needs: test is the second half. Without it the deploy job runs alongside the tests rather than after them, so a failing test does not stop a deploy. It is the same dependency-edge point that governs configuration validation in CI, and it is just as easy to lose during a workflow refactor.
Attaching required reviewers to the production environment adds a human gate where one is wanted, and it composes cleanly: the reviewer approves, the job starts, and only then does the production token become resolvable. Nothing about the test path changes, so the approval gate costs contributors nothing.
Gotchas & version-specific behaviour
- Scope the service token to the ci config and make it read-only.
- Store
DOPPLER_TOKENas a masked CI secret; never inline it. - Prefer
doppler runoverdoppler secrets downloadso values stay in the process, not a file. - Rotate the CI token on a schedule, independent of the secrets it exposes.
- Fork-triggered pipelines do not receive protected secrets on most platforms — verify that yours withholds them rather than assuming, and keep the jobs that run on forks secret-free.
- Pin the CLI install rather than piping the latest script, or the pipeline’s behaviour changes without any commit.
That last point is a supply-chain concern that the convenient one-liner glosses over. curl | sh fetches whatever is current, so a job that passed yesterday can behave differently today with nothing in the repository changed. Pinning a version, or using a maintained action that pins for you, makes the pipeline reproducible — and matters more in a job that handles secrets than in one that does not.
The fork caveat is worth testing rather than trusting, because the consequences of being wrong are severe and the check takes a minute. Open a pull request from a fork of your own repository and look at whether the secret-dependent job runs and what it sees. Most platforms withhold protected values and the job fails on a missing variable, which is the correct outcome; if instead it succeeds, the token is reachable by anyone who can fork, and the pipeline needs restructuring before that becomes an incident.
The related design decision is what the fork pipeline should do at all. Running the unit tests, the linters, and the type checks — none of which need a secret — gives external contributors useful feedback without exposing anything, and it keeps the check they see green-able by their own work. Integration tests that genuinely need credentials belong on a pipeline that only runs after a maintainer has reviewed the change.
Production parity checklist
- The CI token is read-only and scoped to the CI config.
- The token is a masked CI secret.
- Secrets are injected via
doppler run, not written to a file or echoed. - Fetched values are
SecretStr-wrapped in test code. - The token is rotated independently of the secrets.
- The token is scoped to the step that needs it, not to the whole job.
Auditing an existing pipeline against this list is quick. Read the workflow and ask which steps can see DOPPLER_TOKEN, then check in Doppler which config that token resolves to. Those two answers together tell you the real blast radius, and in most pipelines that have grown organically at least one of them is wider than anyone intended.
The GitLab equivalent of every item here exists with different names. Scope the token by defining it as a Protected and Masked CI/CD variable attached to an environment, so unprotected branches never receive it; use needs: to gate the deploy stage on the test stage; and prefer doppler run in the job’s script over any step that writes values to a file the runner will cache. The mechanics differ between the two platforms; the reasoning behind each one does not.
Key takeaways
A read-only, config-scoped Doppler service token gives CI exactly the secrets it needs and nothing more, with no long-lived credential in the pipeline. Two placements do the work: scoping the token to the ci config so it cannot reach production values, and scoping it to the single step that needs it so the rest of the job — including a piped install script — never sees it.
Beyond that, prefer doppler run over downloading to a file so values never outlive the process that needs them, wrap them in SecretStr so a failing test prints a mask, and rotate the token quarterly with provision-verify-revoke. None of it is expensive, and together it means a compromised pipeline exposes test values rather than production ones — which converts what would be a credential-rotation incident across every downstream system into a routine token replacement nobody has to be paged for.
If you adopt one item first, make it the config scope. A CI token that can only read the ci config caps the damage from every other mistake on this page: an accidental set -x, a token pasted into the wrong place, a contributor’s modified workflow. The rest is refinement on top of a boundary that already holds. For the local-development equivalent, see Syncing Doppler Secrets to Local Docker Containers.