Repository navigation
Doppler and Secrets
Before this page: Identity, secrets and zero trust basics explains what a secret is, how secrets leak, and what a secrets manager is for.
The rule is simple: nothing in this repository is a credential, and nothing that identifies the operator's account is in a file either. Every real value lives in Doppler. This page explains where each one lives, how it reaches a run, and how to rotate it.

Diagram source (Mermaid)
flowchart LR
subgraph doppler["Doppler: clouds-against-humanity / prd"]
dot["DIGITALOCEAN_TOKEN<br/>custom write scopes, 90 days"]:::secret
tgt["TWINGATE_API_TOKEN<br/>90 days"]:::secret
tgn["TWINGATE_NETWORK<br/>tenant name, not a credential"]:::warn
adm["ADMIN_SSH_PUBLIC_KEY<br/>ADMIN_SSH_KEY_NAME<br/>ADMIN_USER_IDS"]:::warn
end
subgraph spacelift["Spacelift context cah-doppler"]
st["DOPPLER_TOKEN<br/>read-only, one config, 90 days,<br/>write-only variable"]:::secret
end
subgraph runs["Runs"]
r00["cah-00-foundation<br/>gets: DIGITALOCEAN_TOKEN"]:::cloud
r10["cah-10-access<br/>gets: all of the above"]:::cloud
end
st -- "fetch by name" --> doppler
dot --> r00
dot --> r10
tgt --> r10
tgn --> r10
adm --> r10
classDef secret fill:#3a2a0f,stroke:#fbbf24,color:#fef3c7
classDef warn fill:#2a2410,stroke:#facc15,color:#fef9c3
classDef cloud fill:#0f2a3d,stroke:#38bdf8,color:#e0f2fe
The "gets" lines are the names each stack asks Doppler for, which is least privilege for honest code, not isolation. DOPPLER_TOKEN itself is present in every run through the attached context, so code that someone can get into a run could read it directly and fetch every secret in the config, including the ones stack 00 never asked for. What actually keeps that code out of a run is the push policy and who can push, covered in section 8 of the secrets runbook and on the Spacelift page.
Some of these are not secrets in the "attacker can log in with it" sense (the tenant name, the user ids, the SSH key name), but they identify an account, and this repository is public. They go in Doppler too.
- One place to rotate. Change the value in Doppler and the next run picks it up. No hunting through CI settings and stack variables.
-
Spacelift holds exactly one secret: a read-only service token scoped to this project's
prdconfig. If it leaks, it reads these values and nothing else, and it cannot write. - Consistency across projects. The same pattern is used across the author's other repositories, where GitHub Actions fetches from Doppler over OIDC and holds no stored Actions secrets at all.
Screenshot to take yourself: the Doppler config screen and the service token screen. Capture: the project's
prdconfig showing only secret names (values hidden), and the Access tab listing the read-only service token with its expiry. Look for: that the token is read-only, scoped to one config, and has an expiry date, and that the values column is masked. Redact: every secret value (keep them masked, never reveal one to take a picture), the token itself if it is ever shown (it is shown once, at creation, so do not capture that dialog), the workspace name, and your email.
scripts/spacelift/doppler-env.sh is the only code that sees the Doppler token. It is POSIX sh (the Spacelift runner is Alpine) and fails closed:
-
Skip unless allowed. Only tracked or destroy runs on
mainget anything. Proposed runs and task runs get nothing. -
Check settings.
DOPPLER_TOKEN,DOPPLER_PROJECTandDOPPLER_CONFIGmust come from the attached context. - Install a verified CLI. Downloads a pinned Doppler CLI release over HTTPS and checks it against a pinned SHA-256. A mismatch deletes the file and stops the run.
-
Fetch by name only. Each stack lists exactly the names it needs in
.spacelift/config.yml.TF_VAR_foo=FOOfetchesFOOand exports it asTF_VAR_foo, because Doppler only allows uppercase names and OpenTofu reads lowercaseTF_VAR_names exactly. -
Mask. Every fetched value is passed to Spacelift's
::add-maskso it is redacted in logs.
The script has its own tests (tests/test_doppler_env.py) that run it under plain sh with stubbed doppler and curl, and prove that proposed runs, non-main branches, task runs and bad checksums all get nothing.
Being honest about this matters more than the diagram:
| Value | Also ends up in | Mitigation |
|---|---|---|
| Twingate connector access and refresh tokens | OpenTofu state in Spacelift, and droplet user-data | Marked sensitive; one token per connector; revocable; restrict who can read stack state in Spacelift; a metadata lock is built but off by default until rolled out |
| Admin SSH public key | Droplet user-data and state | It is a public key; the comment is stripped so it carries no user or machine name |
Never paste a token into a chat, issue, commit or log.
| Secret | Cadence | Steps |
|---|---|---|
| DigitalOcean token | 90 days | Generate new token with the same custom scopes, doppler secrets set DIGITALOCEAN_TOKEN (it prompts, so the value stays out of shell history), run stack 00 and expect no changes, revoke the old one |
| Doppler service token | 90 days | Generate a new read-only token for prd, replace DOPPLER_TOKEN in the Spacelift context, run stack 00 and look for doppler-env: loaded DIGITALOCEAN_TOKEN, delete the old token |
| Twingate API token | 90 days | Replace in Doppler; the next tracked run uses it |
| Connector tokens | When needed | Replace the token resource through a Spacelift run, one connector at a time; this rebuilds that droplet |
Expiry dates are kept in a password manager, not in the repository. An expired token warns nobody; the next run just fails, and the troubleshooting table maps each failure message to its cause.
Two things can only be confirmed on a real tracked run, and are worth doing the first time:
- Search the run log for the Doppler token prefix (
dop_v1_) and confirm it never appears. If it does, revoke the token immediately and change the masking approach. - Confirm the hook ran from
/mnt/workspace/source/and that the runner hadcurl,tarandsha256sum. If not, the run fails closed with a clear message.
The full operator guide, including adding secrets and upgrading the pinned CLI, is docs/spacelift-secrets.md.
Next: CI, tests and the cost gate, step 18 of the core path on the Reading order page.
Source for these pages lives in docs/wiki/ and is published by the Wiki workflow on every merge to main. Corrections are welcome as pull requests; edits made in the wiki editor are overwritten on the next publish. Apache-2.0 licensed, see LICENSE and NOTICE in the repository.
Support this project: support.chiefgyk3d.com, or see Home.
Core path: read in this order
- Start here if you are new
- What is the cloud
- Networking basics
- Identity, secrets, zero trust
- Infrastructure as code
- Git and pull requests
- Containers and Docker (optional)
- How money works
- Enterprise habits
- Architecture overview
- Why ZTNA and not a VPN
- Twingate
- DigitalOcean network and firewalls
- Droplet hardening
- OpenTofu, stacks and modules
- Spacelift
- Doppler and secrets
- CI, tests and the cost gate
- Threat model
- Lessons learned
- Roadmap
Track: build it yourself
Track: understand the design
The rest of this track is on the core path; see Reading order.
Track: security and threat modelling
- Threat model exercise
- Threat Dragon exercise
- Threat frameworks: STRIDE, ATT&CK, ATLAS
- Troubleshooting index
The Threat model is core step 19.
Track: detection and monitoring
- A small example: the enterprise landscape
- Detection, Wazuh and the landscape
- Cloud telemetry: what and why (unverified draft)
- Multiple sites and identity (unverified draft)
- Telemetry crosses the trust boundary (unverified draft)
- Reading a dashboard as an operator (unverified draft)
- Build your own mini SOC (unverified draft)
- Stage 2 home-side runbook (unverified draft)
- Grafana dashboards (unverified draft)
- Lab: synthetic multi-site monitoring stack