Setup guide
Everything you must provision before this skill can run โ the exact credentials, permissions, and where to click. Links go to the official consoles and docs, which stay current.
Create a DigitalOcean team with billing enabled, generate a read/write Personal Access Token scoped to apps, databases and domains, export it as DIGITALOCEAN_ACCESS_TOKEN, authenticate doctl, and connect the source repository (or DOCR registry) that App Platform will build from.
You need to already have
- A DigitalOcean account with a verified payment method on the team that will own the app - App Platform instances and Managed Databases bill hourly and cannot be created on a team without billing.
- Owner or Member role on that DigitalOcean team. A Biller-only membership can see invoices but cannot create apps, databases, or tokens.
- doctl v1.100 or newer installed and on PATH (check with `doctl version`); the skill drives almost every operation through doctl.
- For `github:` / `gitlab:` app-spec sources: the DigitalOcean App Platform GitHub (or GitLab) integration installed and granted access to the repository, which requires admin rights on that repo or org.
- For `image:` sources: a DigitalOcean Container Registry containing the tag, and `doctl registry login` run locally if you also push images.
- jq and yq installed locally - the skill's ops.sh converts the YAML spec to JSON for POST /v2/apps/propose and parses the cost response.
- Terraform 1.5+ with the digitalocean/digitalocean provider ~> 2.43 only if the database cluster, pool, and firewall are provisioned as code (db.tf).
- Registrar access for any custom domain: either delegate the zone to ns1/ns2/ns3.digitalocean.com for `type: PRIMARY`, or be able to create ALIAS/ANAME and CNAME records at the external DNS provider.
How the pieces connect
Credentials this skill needs
Set these as environment variables. Never paste secrets into a chat or commit them.
| Variable | What it is | Where to get it |
|---|---|---|
DIGITALOCEAN_ACCESS_TOKENsecret | DigitalOcean Personal Access Token (read/write) format: String beginning `dop_v1_` followed by 64 lowercase hex characters (71 characters total). Displayed exactly once at creation and never retrievable again. | DigitalOcean Control Panel > API (left sidebar, under MANAGE) > Tokens tab > Generate New Token > enter a name, choose an expiration, select Custom Scopes (or Full Access) > Generate Token. Copy the value from the green banner immediately. |
DIGITALOCEAN_TOKENsecretoptional | Same token, under the name the Terraform provider reads format: Identical `dop_v1_<64 hex>` value as DIGITALOCEAN_ACCESS_TOKEN | No separate creation step - the digitalocean/digitalocean Terraform provider reads DIGITALOCEAN_TOKEN (falling back to DIGITALOCEAN_ACCESS_TOKEN). Export both when running db.tf: `export DIGITALOCEAN_TOKEN="$DIGITALOCEAN_ACCESS_TOKEN"`. |
TF_VAR_app_uuidoptional | App Platform app UUID (Terraform var app_uuid, used for the database trusted-source rule) format: UUID v4, e.g. 4f6c9a2b-1d3e-4a58-9b0c-77d2e1f3a4b5 | Run `doctl apps list --format ID,Spec.Name` after the app exists, or open Control Panel > Apps > your app > Settings - the UUID is the last path segment of the app's console URL (cloud.digitalocean.com/apps/<uuid>). |
SESSION_SECRETsecretoptional | Application secret referenced by the sample spec as `type: SECRET` format: Application-defined; generate with `openssl rand -hex 32` | Set out-of-band, never in the committed spec: Control Panel > Apps > your app > Settings > App-Level Environment Variables (or the component's own Environment Variables) > Edit > Encrypt checkbox > Save. Spec reads afterwards return it as `EV[1:...]` ciphertext. |
Permissions to grant
account:readAPI token scopeBacks `doctl account get` / GET /v2/account, the skill's first-step auth check before any billable action.
โณ Cannot be narrowed further; it is already read-only and exposes only team email, status, and droplet limit.
app:readAPI token scopeLists apps, reads the live spec for the .do/app.live.yaml baseline, lists deployments to record the last known-good ID, streams build/deploy/run logs, and calls POST /v2/apps/propose for cost.
โณ For an audit-only or cost-review run, grant app:read plus account:read and nothing else - proposing a spec and reading app_cost needs no write scope.
app:createAPI token scopeCreates a new app from .do/app.yaml (`doctl apps create --spec`).
โณ Omit entirely when the skill only maintains an existing app; app:update alone is enough to deploy changes.
app:updateAPI token scopeApplies spec changes, triggers create-deployment after secret rotation, assigns alert destinations, and performs rollback / rollback commit / rollback revert.
โณ No narrower per-app scope exists on DigitalOcean PATs; isolate blast radius by putting production in its own team with its own token instead.
app:deleteAPI token scopeDestroys abandoned preview apps found during the zombie-spend sweep.
โณ Leave this scope off for production tokens - the skill can report deletion candidates and let a human run the delete.
database:readAPI token scopeLists clusters to confirm engine, version, node size, and datacenter colocation, and reads connection-pool URIs and existing firewall rules.
โณ Sufficient on its own for the connection-sizing and colocation checks.
database:createAPI token scopeCreates the Managed PostgreSQL/MySQL/Valkey cluster and the transaction-mode connection pool used by the app.
โณ Skip when the cluster already exists and is merely being attached via `cluster_name` in the app spec.
database:updateAPI token scopeAppends the `app:<app-uuid>` trusted-source firewall rule that locks the cluster to this app, and resizes or reconfigures pools.
โณ Required for the firewall step; there is no separate firewall-only scope.
domain:createAPI token scopeCreates the DigitalOcean-hosted zone and the A/ALIAS/CNAME records that back a `type: PRIMARY` domain in the app spec so Let's Encrypt can validate.
โณ Not needed when DNS is external - use `type: ALIAS`/CNAME at the registrar and drop this scope.
domain:readAPI token scopeVerifies the records DigitalOcean expects actually resolve before certificate issuance and the DOMAIN_FAILED alert fires.
โณ Read-only; safe to grant alongside domain:create.
container_registry:readAPI token scopeResolves `registry.digitalocean.com/<registry>/<image>:<tag>` sources when the component deploys a prebuilt image instead of building from Git.
โณ Omit for buildpack or Dockerfile-from-Git deployments, which never touch DOCR.
Step by step
- 1
Confirm the team and enable billing
Control Panel > top-left team switcher > select (or create) the team that will own the app > Settings > Billing > add a payment method. App Platform paid instance sizes and every Managed Database are rejected on a team with no valid payment method.
Open in the console / docs โ - 2
Generate a scoped Personal Access Token
Control Panel > API (left sidebar, under MANAGE) > Tokens tab > Generate New Token. Name it (e.g. `app-deployer-prod`), pick an expiration (30/60/90 days, 1 year, or No expiry), then choose Custom Scopes and tick: account (read); app (create, read, update, and delete only if you want the zombie-app sweep to delete); database (create, read, update); domain (create, read); container_registry (read) if deploying DOCR images. Click Generate Token and copy the `dop_v1_...` value immediately - it is shown once.
Open in the console / docs โ - 3
Export the token for the skill and for Terraform
In the shell that runs the skill: `export DIGITALOCEAN_ACCESS_TOKEN='dop_v1_...'` and, if you will run db.tf, `export DIGITALOCEAN_TOKEN="$DIGITALOCEAN_ACCESS_TOKEN"`. The skill's ops.sh hard-fails with `export a read/write API token` when DIGITALOCEAN_ACCESS_TOKEN is unset. Never commit the value; put it in a secret manager or a gitignored .env.
Open in the console / docs โ - 4
Install doctl and initialize an auth context
Install doctl (Homebrew `brew install doctl`, Snap, Scoop `scoop install doctl`, or the GitHub release archive), then run `doctl auth init --context prod` and paste the token when prompted. Switch contexts later with `doctl auth switch --context prod`. Credentials land in ~/.config/doctl/config.yaml (%APPDATA%\doctl\config.yaml on Windows).
Open in the console / docs โ - 5
Verify the token and its scopes
Run `doctl account get` (proves account:read) and `doctl apps list --format ID,Spec.Name,DefaultIngress,ActiveDeployment.Phase` (proves app:read). A 401 means the token is wrong or revoked; a 403 on the second command means the app scope was not ticked when the token was created - tokens cannot be re-scoped after creation, so generate a new one.
Open in the console / docs โ - 6
Connect the source repository to App Platform
Control Panel > Apps > Create App > choose GitHub / GitLab / Bitbucket > Manage Access, which sends you to the provider to install the DigitalOcean App Platform integration and select the repositories it may read. This OAuth link is per-team and cannot be created from the API - do it once before the first `doctl apps create`, otherwise the spec's `github: { repo: acme/api }` fails to resolve.
Open in the console / docs โ - 7
Create the Managed Database in the matching datacenter
Control Panel > Databases > Create Database Cluster > engine (PostgreSQL 17) > datacenter that matches the app region (nyc3 for a `region: nyc` app) > node plan > name it `api-pg`. Or run db.tf with `terraform init && terraform apply`. Note the cluster UUID from `doctl databases list --format ID,Name,Engine,Region`.
Open in the console / docs โ - 8
Lock the cluster to the app and add a connection pool
After the app exists: `doctl databases firewalls append <cluster-id> --rule app:<app-uuid>` then `doctl databases pool create <cluster-id> --name app-pool --mode transaction --size 20 --db defaultdb --user doadmin`. Confirm with `doctl databases firewalls list <cluster-id>` and `doctl databases pool get <cluster-id> app-pool --format URI`. IP allowlists do not work because App Platform egresses from shared ranges.
Open in the console / docs โ - 9
Delegate DNS if the spec uses type: PRIMARY
Control Panel > Networking > Domains > enter the apex domain > Add Domain, then set the registrar's nameservers to ns1.digitalocean.com, ns2.digitalocean.com, ns3.digitalocean.com. For external DNS instead, leave the domain as `type: ALIAS` in the spec and create the CNAME/ALIAS records DigitalOcean reports after `doctl apps update`. Apex records cannot be plain CNAMEs.
Open in the console / docs โ - 10
Dry-run the spec before spending money
`doctl apps spec validate .do/app.yaml` for schema plus server-side semantics (add `--schema-only` for offline iteration), then `yq -o=json '{"spec": .}' .do/app.yaml > /tmp/propose.json` and POST it to https://api.digitalocean.com/v2/apps/propose to read `app_cost` before applying. This requires only app:read.
Open in the console / docs โ
Check it worked
curl -sS -o /dev/null -w 'account:%{http_code} ' -H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" https://api.digitalocean.com/v2/account && curl -sS -o /dev/null -w 'apps:%{http_code} ' -H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" 'https://api.digitalocean.com/v2/apps?per_page=1' && curl -sS -o /dev/null -w 'databases:%{http_code}\n' -H "Authorization: Bearer $DIGITALOCEAN_ACCESS_TOKEN" 'https://api.digitalocean.com/v2/databases?per_page=1' # expect: account:200 apps:200 databases:200If you hit an error
Error: GET https://api.digitalocean.com/v2/account: 401 Unable to authenticate you.Cause: DIGITALOCEAN_ACCESS_TOKEN is unset, truncated, expired, or was revoked; or doctl is using a stale context from a previous `doctl auth init`.
Fix: Re-export the full `dop_v1_...` value (71 chars, no trailing newline), or run `doctl auth list` and `doctl auth switch --context <name>`. Expired tokens cannot be renewed - generate a new one at cloud.digitalocean.com/account/api/tokens.
You are not authorized to perform this operation.Cause: The token was created with Custom Scopes that omit the scope this call needs (typically app:update, database:update, or domain:create), or the caller is a Biller-only team member.
Fix: Scopes are fixed at creation. Generate a replacement token with the missing scope ticked, or ask a team Owner to raise your role. Confirm which call failed by re-running it with `doctl ... --verbose`.
API Rate limit exceeded.Cause: More than 5,000 requests per hour (or 250 burst) against api.digitalocean.com from this token - usually a tight `doctl apps logs --follow` loop or a polling script.
Fix: Back off and read RateLimit-Remaining / RateLimit-Reset from the response headers; replace polling with `doctl apps update --wait` / `doctl apps create-deployment --wait`, which stream server-side.
ERROR: No buildpack groups passed detection. / failed to detect: no buildpacks participatingCause: Cloud Native Buildpacks could not identify the runtime - wrong `source_dir`, a missing package.json/requirements.txt/go.mod at that path, or an `environment_slug` that does not match the repo layout.
Fix: Set `source_dir` to the subdirectory that actually contains the manifest, pin `environment_slug` explicitly, or switch the component to `dockerfile_path`. Inspect with `doctl apps logs <app-id> <component> --type build --tail 200`.
DeployContainerHealthChecksFailedCause: The container started but the health check never returned 200 - almost always a service bound to 127.0.0.1 instead of 0.0.0.0, an `http_port` that does not match the injected `PORT`, or `initial_delay_seconds` shorter than real cold-start.
Fix: Bind `0.0.0.0:$PORT`, make `http_port` match, raise `initial_delay_seconds` above measured boot time, and keep `/healthz` free of database calls. Confirm with `doctl apps logs <app-id> <component> --type run_restarted`.
FATAL: sorry, too many clients alreadyCause: instance_count x per-process client pool exceeded the Managed PostgreSQL backend ceiling (roughly 25 x RAM_GB - 3, so about 22 on a 1 GB node).
Fix: Point DATABASE_URL at a transaction-mode connection pool (`doctl databases pool create ... --mode transaction --size 20`) and cap the application-side pool, instead of upsizing the cluster to buy connections.
FATAL: no pg_hba.conf entry for host "...", user "doadmin", database "defaultdb", SSL offCause: The client disabled TLS or a hand-built connection string dropped `sslmode=require`; DigitalOcean Managed Databases refuse plaintext connections.
Fix: Use the bindable `${db.DATABASE_URL}` (it already carries `sslmode=require`) rather than assembling a URL from host/port/user, and do not set `sslmode=disable` in the client.
dial tcp <cluster-host>:25060: i/o timeoutCause: The cluster's trusted sources do not include this app, so the connection is dropped before the TLS handshake - IP allowlists never work here because App Platform egresses from shared ranges.
Fix: `doctl databases firewalls append <cluster-id> --rule app:<app-uuid>` (or the `digitalocean_database_firewall` rule with `type = "app"`), then verify with `doctl databases firewalls list <cluster-id>`.
Official documentation: https://docs.digitalocean.com/products/app-platform/ โ
API operations this skill uses (20)
Every call the skill makes, linked to its official reference page.
| Operation | Call | Permission | Ref |
|---|---|---|---|
| Verify token / get account Step 1 of the procedure: prove the token authenticates before any billable or traffic-moving action. | GET /v2/account (doctl account get) | account:read | docs โ |
| List apps Resolve the app UUID from its name, and drive the zombie-spend sweep for abandoned preview apps. | GET /v2/apps?per_page=200 (doctl apps list --format ID,Spec.Name,DefaultIngress,ActiveDeployment.Phase) | app:read | docs โ |
| Get app / read live spec Snapshot .do/app.live.yaml as the pre-change baseline, since App Platform keeps no spec history to diff against. | GET /v2/apps/{app_id} (doctl apps spec get <app-id> --format yaml) | app:read | docs โ |
| Validate spec and price it (propose) Server-side schema and semantic validation with no deployment created, plus app_cost / app_tier_upgrade_cost / app_tier_downgrade_cost for the budget gate. | POST /v2/apps/propose (doctl apps spec validate .do/app.yaml) | app:read | docs โ |
| Create app Stand up a new app from the authored spec after validation and pricing pass. | POST /v2/apps (doctl apps create --spec .do/app.yaml --wait) | app:create | docs โ |
| Update app spec Apply component, env-var, autoscaling, domain, and alert changes; this is the call that triggers a new deployment. | PUT /v2/apps/{app_id} (doctl apps update <app-id> --spec .do/app.yaml --wait) | app:update | docs โ |
| Create deployment (redeploy unchanged code) Ship a secret rotation or a gated production release without editing the spec, instead of relying on deploy_on_push. | POST /v2/apps/{app_id}/deployments (doctl apps create-deployment <app-id> --wait [--force-rebuild]) | app:update | docs โ |
| List deployments Record the last known-good ACTIVE deployment ID before every apply so rollback is possible. | GET /v2/apps/{app_id}/deployments (doctl apps list-deployments <app-id> --format ID,Phase,Cause,Created) | app:read | docs โ |
| Get deployment status Poll phase and per-component progress (including the PRE_DEPLOY job's exit) when not using --wait. | GET /v2/apps/{app_id}/deployments/{deployment_id} | app:read | docs โ |
| Retrieve component logs Read build failures, PRE_DEPLOY migration output, and crash loops from the correct stream instead of guessing. | GET /v2/apps/{app_id}/deployments/{deployment_id}/components/{component_name}/logs?type=BUILD|DEPLOY|RUN|RUN_RESTARTED (doctl apps logs <app-id> <component> --type build|deploy|run|run_restarted) | app:read | docs โ |
| List instance sizes and prices Confirm current apps-s-* / apps-d-* slugs and USD prices before quoting a cost delta or enabling autoscaling (which requires a dedicated size). | GET /v2/apps/tiers/instance_sizes | app:read | docs โ |
| Assign alert destinations Route DEPLOYMENT_FAILED, DOMAIN_FAILED, CPU_UTILIZATION, MEM_UTILIZATION, and RESTART_COUNT alerts to real emails or Slack. | POST /v2/apps/{app_id}/alerts/{alert_id}/destinations (doctl apps update-alert-destinations <app-id> <alert-id> --alert-destinations <file>) | app:update | docs โ |
| Roll back to a prior deployment (pin) Phase one of the two-phase rollback: restore the last known-good deployment while the app stays pinned. | POST /v2/apps/{app_id}/rollback body: {"deployment_id":"<prev>"} | app:update | docs โ |
| Commit rollback Phase two: unpin the app so normal deployments resume - skipping it blocks every future deploy. | POST /v2/apps/{app_id}/rollback/commit | app:update | docs โ |
| Revert rollback Abandon a pinned rollback and return to the deployment that was active before the rollback was started. | POST /v2/apps/{app_id}/rollback/revert | app:update | docs โ |
| List database clusters Confirm engine/version, node sizing, and that the cluster datacenter is colocated with the app region; also finds idle dev clusters during the cost sweep. | GET /v2/databases (doctl databases list --format ID,Name,Engine,Version,Region,Size,NumNodes) | database:read | docs โ |
| Create database cluster Provision the production PostgreSQL cluster the app spec attaches to via `cluster_name` / `production: true`. | POST /v2/databases (terraform apply on digitalocean_database_cluster) | database:create | docs โ |
| Create connection pool Keep total backends under the roughly 25 x RAM_GB - 3 ceiling instead of upsizing the cluster to buy connections. | POST /v2/databases/{database_cluster_uuid}/pools (doctl databases pool create <cluster-id> --name app-pool --mode transaction --size 20 --db defaultdb --user doadmin) | database:create | docs โ |
| Set database trusted sources (firewall) Restrict the cluster to this app UUID, the only workable lock since App Platform egresses from shared IP ranges. | PUT /v2/databases/{database_cluster_uuid}/firewall (doctl databases firewalls append <cluster-id> --rule app:<app-uuid>) | database:update | docs โ |
| Create DNS record for a custom domain Publish the records App Platform reports after adding `domains:` so Let's Encrypt validation and TLS issuance can complete. | POST /v2/domains/{domain_name}/records (doctl compute domain records create example.com --record-type CNAME --record-name api --record-data <app>.ondigitalocean.app.) | domain:create | docs โ |