# Sync to DigitalOcean

App Platform holds environment variables in the app spec and hands them to your
components when it deploys them, so there is no earlier point at which seekrit
could inject them. A binding writes one app's variables — either the app-level
set every component inherits, or one component's own.

| At a glance | |
| --- | --- |
| **What seekrit writes** | An app's or component's `envs`, as App Platform **secrets** (`type: SECRET`) |
| **Addressed by** | The app's UUID, optionally plus a component name |
| **Connection carries** | The token alone — a DigitalOcean token belongs to one account |
| **Token permission** | `app:read` + `app:update` (a full-access token has both) |
| **Takes effect** | A new deployment, started by the push itself |
| **Value visibility** | Encrypted at rest by DigitalOcean, shown as `EV[1:…]` in the app spec |

New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the
decryption grant, name mapping, deletions, and failure handling are the same on
every destination.

## 1. Create a personal access token

In DigitalOcean, go to **API → Tokens** and create a personal access token with
**`app:read`** and **`app:update`**. The token belongs to one account and carries
that scope itself, so a DigitalOcean connection has nothing else to configure:

```bash
printf '%s' "$DIGITALOCEAN_TOKEN" | seekrit sync connect \
  --name acme-digitalocean --provider digitalocean
```

## 2. Bind an environment

A binding writes into one **App Platform** app, named by the UUID in its
dashboard URL (`cloud.digitalocean.com/apps/<id>`, or `doctl apps list`) — not
by the app's name, which `GET /v2/apps/{id}` answers with a flat 404. Without
`--component`, it writes the app-level variables that every component inherits:

```bash
seekrit sync verify acme-digitalocean --provider digitalocean \
  --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf

seekrit sync enable --connection acme-digitalocean --provider digitalocean \
  --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf \
  --app storefront --env production --acknowledge-decryption
```

Pass `--component` to write one component's own variables instead. A
component-level variable **overrides** an app-level one of the same name, which
is App Platform's rule, not seekrit's:

```bash
seekrit sync enable --connection acme-digitalocean --provider digitalocean \
  --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf --component api \
  --app storefront --env api-production --acknowledge-decryption
```

A component is named as it appears in the app spec — `services`, `workers`,
`jobs`, `static_sites`, and `functions` all qualify. Databases do not: a database
component has no `envs` of its own, it *supplies* bindable values to the ones
that do.

### Run time, build time, or both

Values are written as App Platform **secrets** (`type: SECRET`), so DigitalOcean
encrypts them at rest and shows them as `EV[1:…]` in the app spec rather than in
the clear.

By default they are scoped to **run time**, which keeps them out of build logs
and buildpacks. That is deliberately narrower than DigitalOcean's own default of
`RUN_AND_BUILD_TIME`: a build-time variable is visible to every build command,
every buildpack, and anything they print. Pass `--env-scope BUILD_TIME` or
`--env-scope RUN_AND_BUILD_TIME` for a value a build genuinely needs — a private
registry token, a sourcemap upload key.

> **Warning:** **A push deploys the app.** App Platform has no per-variable endpoint: environment variables live in the app spec, and the only way to change one is to submit a new spec, which starts a new deployment. That is true of the control panel and `doctl` too — it is not something seekrit adds. The deployment reuses each component's current commit or image digest, so it redeploys the code already running and never ships a newer build. But it is a real deployment: a build, a health check, and a rollout. Bind an environment here knowing that changing a secret in it will roll the app.

## How a push behaves

**The whole app spec is the unit of writing.** `PUT /v2/apps/{id}` replaces the
spec entirely — every component, route, database, domain, and alert in one
document. So a run reads the current spec, changes exactly one `envs` array
inside it, and sends the rest back byte for byte.

That makes one rule absolute: **the spec is opaque.** seekrit parses it as an
untyped document, mutates it at one path, and re-serializes — never mapping it
onto a model of what an app spec contains. DigitalOcean's own public OpenAPI
document is the reason: it omits `envs`, `alerts`, and `features`, all of which
its Go client has and real apps use. Round-tripping through any hand-written
shape would silently delete whatever that shape forgot, and the thing deleted
would be a customer's production config.

So your components, routes, databases, domains, and alerts survive a push, and so
does any variable seekrit did not write — including another team's encrypted
secrets, which pass through as opaque blobs. A removal drops just that name.

> **Note:** **seekrit cannot tell a redundant push from a real one here.** Everything it writes goes in as `type: SECRET`, so App Platform hands back an opaque `EV[1:…]` blob on the next read. A connector holding plaintext and reading back ciphertext cannot compare the two, so a run with anything to push, pushes. This is a property of the platform, not of this connector — DigitalOcean's own Terraform provider has the same reported problem. If it matters, keep the binding's `include`/`exclude` narrow, so unrelated changes elsewhere in the environment don't trigger a deployment.

## Name rules

App Platform accepts environment variable names matching `[_A-Za-z][_A-Za-z0-9]*`
— which is seekrit's own rule too, so only a binding's **name transform** can
produce a name that fails it. A name that breaks the rule is reported as a
failure against that name alone; the rest of the environment still pushes. Watch
for it if you use a `--prefix`.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401` or `403`, whole run stopped | The token lacks `app:update`, or was revoked | Both `app:read` and `app:update` are required |
| `404` on the app, whole run stopped | The app's **name** was used instead of its UUID | Take the UUID from `cloud.digitalocean.com/apps/<id>` or `doctl apps list` |
| A UUID is rejected when enabling | Not a UUID — usually the app name | seekrit checks the shape up front, because the API answers a name with a flat 404 |
| `404` mentioning the component | The component name isn't in the app spec, or is a database | Use the name as the spec writes it; databases have no `envs` |
| The app redeployed when nothing changed | seekrit cannot compare encrypted values, so any run with something to push, pushes | Narrow the binding's `include`/`exclude` |
| A build can't see a value | Values default to `RUN_TIME` | Pass `--env-scope BUILD_TIME` or `RUN_AND_BUILD_TIME` — and only for values a build genuinely needs |
| A name failed but the rest landed | The mapped name isn't `[_A-Za-z][_A-Za-z0-9]*` | Fix the prefix or rename that produced it |

## See also

- [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions
- [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag
