▸ Agent Skills
10 min read

Doppler Provider

Doppler integration over the Doppler REST API, with verbatim secret names

New in version 0.21

The Doppler provider reads and writes secrets in a Doppler project over Doppler’s REST API. No doppler CLI is required.

Doppler stores secrets as a flat set of names inside a config, and configs are Doppler’s environment axis. SecretSpec maps its profile onto that axis, so secrets keep the names your application actually reads.

At a glance

Providerdoppler (0.21+)
URIdoppler://PROJECT[/CONFIG]
AccessRead, write, and delete
Best forTeams already centralizing secrets in Doppler
AuthenticationDOPPLER_TOKEN, or the token provider credential
AvailabilitySecretSpec 0.21+; included in official and default builds (doppler feature for custom minimal builds)
Default storageSecret {key}, verbatim, in the config named by the profile

Quick start

# Authenticate
$ export DOPPLER_TOKEN="dp.sa.xxxxxxxx"


# Store a secret in the `prd` config of project `myapp`
$ secretspec set DATABASE_URL --provider "doppler://myapp/prd"


# Read it back
$ secretspec get DATABASE_URL --provider "doppler://myapp/prd"


# Resolve the profile and run a command
$ secretspec run --provider "doppler://myapp/prd" -- npm start

Setup

Prerequisites

  • SecretSpec 0.21 or newer
  • A Doppler project
  • A config in that project for each profile you read, unless the URI pins one

The official SecretSpec CLI includes this provider. Custom minimal Rust builds enable it with --features doppler.

Doppler projects and configs must already exist. A write cannot create them, so create them in Doppler (or with doppler setup) first.

Authentication

Set DOPPLER_TOKEN, the same variable Doppler’s own CLI uses:

$ export DOPPLER_TOKEN="dp.sa.xxxxxxxx"
$ secretspec check --provider "doppler://myapp/prd"

Any Doppler token authenticates. Two are worth naming, and they differ in what they can address:

TokenPrefixScope
Service accountdp.sa.Workplace-wide, limited by its grants
Service tokendp.st.Exactly one project and one config

A personal (dp.pt.) or CLI (dp.ct.) token — what doppler login leaves on a developer machine — also authenticates, but Doppler does not serve a restricted secret’s value to a token tied to a user identity. Such a read is refused rather than answered, so prefer a service or service account token wherever restricted secrets are in play.

A service token is pinned by Doppler to one config, and a request for any other is refused (see Security considerations). Use a service account token when one SecretSpec configuration spans several configs — for example when the profile names the config.

The provider also declares token as a provider credential, so the token can be loaded from another provider instead of the environment:

secretspec.toml

[providers]
bootstrap = "keyring://"


[providers.doppler_prod]
uri = "doppler://myapp/prd"
credentials = { token = "bootstrap" }


[profiles.production]
DATABASE_URL = { description = "Database URL", providers = ["doppler_prod"] }

Provider credentials

CredentialEnvironment fallbackAvailable since
tokenDOPPLER_TOKEN0.21+

In 0.21+, credentials retain their exact bytes during resolution. Interfaces that require text validate UTF-8 when consuming the credential; invalid input returns an error without selecting an environment fallback. Unix CLI environments accept non-UTF-8 bytes, but cannot contain NUL bytes. A configured credential that resolves to an empty value is an error rather than a fall through to the environment.

See the complete provider credential reference for all supported providers and environment fallbacks.

Configuration

URI format

doppler://PROJECT[/CONFIG]
  • PROJECT is required. It is the Doppler project holding the secrets.
  • CONFIG is optional. When omitted, the SecretSpec profile names the config.

The URI carries nothing else. Query parameters, fragments, user information and ports are rejected rather than ignored, so neither doppler://myapp?config=prd nor doppler://myapp#prd (a typo of / as #) can silently leave the profile naming the config and read a different Doppler environment.

The project is deliberately required rather than taken from the token; see Security considerations.

URI examples

doppler://myapp          # the profile names the config
doppler://myapp/prd      # every profile reads the prd config

Project configuration

Profile names are yours to choose, so the simplest configuration is to name them after the Doppler configs they read. Then one alias covers every profile and there is no mapping to maintain:

secretspec.toml

[providers]
doppler_app = "doppler://myapp"


[defaults]
providers = ["doppler_app"]


[profiles.dev]
DATABASE_URL = { description = "Database URL" }


[profiles.prd]
DATABASE_URL = { description = "Database URL" }

Every secret inherits the project default, so secretspec run --profile prd reads the prd config, and --profile dev reads dev. A profile can name a config whenever Doppler can spell it as one — lowercase letters, numbers, underscores and hyphens (see Secret names Doppler cannot store).

When profile names cannot match the configs

An existing manifest may already use profiles such as production, against a Doppler config named prd; a profile may also be spelled in a way Doppler cannot use as a config name, such as Production. Neither needs a new provider option: declare one alias per Doppler config and point each profile at the one it reads.

secretspec.toml

[providers]
doppler_dev = "doppler://myapp/dev"
doppler_prod = "doppler://myapp/prd"


[profiles.development.defaults]
providers = ["doppler_dev"]


[profiles.production.defaults]
providers = ["doppler_prod"]


[profiles.development]
DATABASE_URL = { description = "Database URL" }


[profiles.production]
DATABASE_URL = { description = "Database URL" }

Every secret in a profile inherits that profile’s defaults.providers, so the mapping costs one entry per profile rather than one per secret. Each profile keeps its own Doppler config, and secret names stay verbatim in all of them.

A single secret can still name its own provider, which overrides the profile default — useful when one value lives in a config shared across environments:

secretspec.toml

[providers]
doppler_prod = "doppler://myapp/prd"
doppler_shared = "doppler://myapp/shared"


[profiles.production.defaults]
providers = ["doppler_prod"]


[profiles.production]
# Read from the prd config, via the profile default.
DATABASE_URL = { description = "Database URL" }
# Read from the shared config instead.
SHARED_CA = { description = "CA bundle", providers = ["doppler_shared"] }

Storage model

A secret is stored under its own key, verbatim, in the config named by the profile:

project "myapp", profile "production", key "DATABASE_URL"
  -> Doppler project myapp        (from the URI)
     Doppler config  production   (the profile, or the pinned CONFIG)
     secret name     DATABASE_URL (verbatim)

Verbatim names are the point. Your application reads DATABASE_URL from its environment, and the same secret is readable through doppler run, the Doppler dashboard, and any existing Doppler integration. Because Doppler has a native environment axis, mapping the profile onto a config gives real profile isolation and verbatim names.

The config comes from the active profile, which is not always the profile a secret is declared in. A secret declared only in [profiles.default] and read while the production profile is active is looked up in Doppler’s production config, not in default: profile inheritance fills in a secret’s declaration, it does not change where the value is stored. Give each config its own copy of such a value, or point each profile at the config it should read using defaults.providers as shown above.

A batch read asks Doppler only for the secrets your manifest declares, so a config holding many more of them is not pulled into the process wholesale, and discovery reads only secret names, never values. One request covers a config; secrets spread across several configs are read concurrently, and a manifest too large to name in one request URI is split across several. A request Doppler rate-limits or fails with a server error is retried after the wait Doppler suggests, up to three attempts, so one 429 does not fail a whole profile.

SecretSpec’s own project name is unused: the Doppler project in the URI provides the namespace. Two SecretSpec projects pointing at one doppler://project/config therefore share a namespace — give them different Doppler projects or configs to keep them apart.

Secret names Doppler cannot store

Doppler spells secret names as ^[A-Z_][A-Z0-9_]*$: uppercase letters, digits and underscores, and never a leading digit. A leading underscore is allowed.

A name outside that alphabet is refused, not rewritten to fit. Rewriting could land two distinct keys on one name, and the collision would be invisible. Rename the secret in secretspec.toml instead.

Config names are ^[a-z0-9_-]+$, so a capitalized profile such as Production cannot name a config. Rename the profile, or pin a config in the URI.

Project names follow the same ^[a-z0-9_-]+$ rule. An uppercase project is refused rather than accepted, because Doppler silently lowercases it: without the refusal, doppler://MyApp and doppler://myapp would name one store under two different URIs, and SecretSpec treats that URI as the store’s identity for cache routing and audit records.

Values Doppler cannot store unchanged

Because Doppler resolves ${...} references (see below), a value containing ${ cannot survive being written and read back: Doppler refuses a reference it cannot resolve, and rewrites one it can. SecretSpec therefore refuses such a value on write rather than storing something that reads back differently.

This matters most when Doppler is used as a cache for another store: a cached value that happened to contain ${...} would otherwise come back as a different secret while still looking fresh.

Doppler’s reserved names

Doppler injects DOPPLER_PROJECT, DOPPLER_CONFIG and DOPPLER_ENVIRONMENT into every config’s secret listing. SecretSpec asks Doppler to leave them out of every listing it requests, and filters them from reads and from discovery as well, because Doppler also refuses to write them — a value under one of those names can never be a secret SecretSpec stored. Asking Doppler to exclude them is what keeps a name it starts injecting later out of discovery too.

Declaring one of those names is therefore refused before secretspec set prompts for a value, rather than after Doppler rejects the write.

Values Doppler withholds

Doppler can report a secret that exists with a visibility but no value. That is distinguishable from an unset secret, which reports no visibility either, so SecretSpec reports it as a refusal naming the secret rather than as a secret that is not set. Reporting it as unset would let secretspec check offer to set — and overwrite — a value it was never allowed to read.

This is what a restricted secret read with a personal or CLI token looks like: Doppler withholds the value from any token tied to a user identity. Use a service or service account token, or lower the secret’s visibility.

Deleting such a secret is refused as well. Doppler would accept the write, but the withheld read is proof that a value exists which this token cannot see, and SecretSpec does not null a value nobody could verify. This is also what keeps secretspec cache clear from deleting a restricted secret that happens to sit at a cache address in a Doppler config. Use a service or service account token to delete it.

A masked secret is not withheld: masking affects how Doppler’s dashboard displays a value, not whether the API returns it.

Secret references

Doppler resolves ${OTHER_SECRET} references between secrets in a config. SecretSpec serves the resolved value, which is what doppler run injects too:

DATABASE_URL = postgres://${DB_HOST}/app
DB_HOST      = db.internal


secretspec get DATABASE_URL  ->  postgres://db.internal/app

One consequence is deliberate: a value containing a literal ${...} cannot survive a write followed by a read, because Doppler resolves it on the way out.

If the secret a reference points at is deleted, Doppler serves the unresolved template instead — postgres://${DB_HOST}/app, verbatim — and SecretSpec passes that through, because it is exactly what doppler run injects in the same situation. Deleting a secret other secrets reference therefore changes their values into templates rather than raising an error, so remove the references first.

Use existing secrets

A secret’s ref selects a secret by config and name, as config/NAME:

secretspec.toml

[providers]
doppler_app = "doppler://myapp"


[profiles.production]
SHARED_CA = { description = "CA bundle kept in a shared config", providers = ["doppler_app"], ref = { item = "shared/SHARED_CA" } }

When the URI pins a config, a bare name is read from it; when it does not, the active profile names the config, exactly as for any other secret:

secretspec.toml

[providers]
doppler_prod = "doppler://myapp/prd"


[profiles.production]
SHARED_CA = { description = "CA bundle", providers = ["doppler_prod"], ref = { item = "SHARED_CA" } }

Doppler secrets are single values with no sub-components, so field, section, vault and version are rejected rather than ignored.

Discover secret names

Doppler lists a config’s secret names without their values, so SecretSpec can discover declarations from an existing config:

$ secretspec init --from doppler://myapp/prd \
    --project my-app --profile production

The generated manifest contains required declarations for the listed names, not defaults or secret values. Doppler’s own DOPPLER_PROJECT, DOPPLER_CONFIG and DOPPLER_ENVIRONMENT are left out.

CI/CD

Store a Doppler token as one protected CI secret:

$ export DOPPLER_TOKEN="$CI_DOPPLER_TOKEN"
$ secretspec run --provider "doppler://myapp/prd" -- ./deploy

Prefer a service token (dp.st.) scoped to the single config the job needs, and grant write access only when the job must run set or refresh a Doppler-backed cache.

Security considerations

SecretSpec talks to https://api.doppler.com over TLS and sends the token in an Authorization: Bearer header, never in a URL or a child-process argument. The token is not part of the provider URI, so the URI recorded in audit logs and fallback-chain warnings carries no credential.

Every request names its project and config explicitly. This matters for service tokens: a request that omits them is answered from the token’s own pinning, so omitting them would let a token swapped from one config to another silently change which secrets a process receives. Naming them turns that into an explicit Doppler refusal.

Secret values are read from Doppler’s computed field, so a value assembled from references is resolved before it reaches your process rather than arriving as a literal ${...} template that fails later, far from its cause.


Last updated Oct 11, 2026