โ† DigitalOcean App Deployer

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.

Some setupโ‰ˆ 20-40 mindigitalocean

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.

VariableWhat it isWhere to get it
DIGITALOCEAN_ACCESS_TOKEN
secret
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_TOKEN
secretoptional
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_uuid
optional
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_SECRET
secretoptional
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 scope

Backs `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 scope

Lists 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 scope

Creates 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 scope

Applies 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 scope

Destroys 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 scope

Lists 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 scope

Creates 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 scope

Appends 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 scope

Creates 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 scope

Verifies 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 scope

Resolves `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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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:200

If 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 participating

Cause: 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`.

DeployContainerHealthChecksFailed

Cause: 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 already

Cause: 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 off

Cause: 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 timeout

Cause: 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.

OperationCallPermissionRef
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:readdocs โ†—
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:readdocs โ†—
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:readdocs โ†—
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:readdocs โ†—
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:createdocs โ†—
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:updatedocs โ†—
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:updatedocs โ†—
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:readdocs โ†—
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:readdocs โ†—
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:readdocs โ†—
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_sizesapp:readdocs โ†—
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:updatedocs โ†—
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:updatedocs โ†—
Commit rollback
Phase two: unpin the app so normal deployments resume - skipping it blocks every future deploy.
POST /v2/apps/{app_id}/rollback/commitapp:updatedocs โ†—
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/revertapp:updatedocs โ†—
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:readdocs โ†—
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:createdocs โ†—
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:createdocs โ†—
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:updatedocs โ†—
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:createdocs โ†—