Use when deploying or operating a workload on DigitalOcean — Droplet vs App Platform vs Functions, doctl, app spec YAML, Managed Postgres/MySQL/Valkey on the VPC, S3-compatible Spaces + CDN. NOT host-agnostic CI/CD or rollback strategy (that is `deployment`), NOT a bare Hetzner VPS (that is `hetzner`), NOT another managed PaaS (that is `railway`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill digitalocean --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Digitalocean?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-digitalocean)More formats (shields.io, HTML) on the badges page.
---
name: digitalocean
description: "Use when deploying or operating a workload on DigitalOcean — Droplet vs App Platform vs Functions, doctl, app spec YAML, Managed Postgres/MySQL/Valkey on the VPC, S3-compatible Spaces + CDN. NOT host-agnostic CI/CD or rollback strategy (that is `deployment`), NOT a bare Hetzner VPS (that is `hetzner`), NOT another managed PaaS (that is `railway`)."
tags: [digitalocean, doctl, app-platform, droplets, spaces, deployment, paas, vps]
recommends: [deployment, postgresdb, docker, domains-dns, monitoring, scaling, hetzner]
origin: risco
---
# DigitalOcean — Droplet vs App Platform, doctl, Managed DBs, Spaces
You own one decision: **where on DO does this run, and how do I ship it.** Host-agnostic
CI/CD, release gating and rollback strategy are `../deployment/SKILL.md`; authoring the
Dockerfile is `docker`; DNS records and the registrar are `domains-dns`; a bare Hetzner box
and its economics are `hetzner`. Pick the compute model first, then wire data and storage,
then the ops that bite in production.
```text
workload → [Droplet | App Platform | Functions] → Managed DB (VPC private host) → Spaces (S3 + CDN)
raw VPS managed PaaS event fn Postgres/MySQL/Valkey object store
```
## The core decision: Droplet vs App Platform vs Functions
Settle this before writing a single command. Most web apps want App Platform; reach for a
Droplet only when you need the box itself.
| Axis | Droplet (VPS) | App Platform (PaaS) | Functions |
| --- | --- | --- | --- |
| Control | Full root, any daemon, any port | Build+run only, no SSH | Per-invocation, no host |
| Ops burden | You patch/secure/restart it | DO runs it, auto TLS, auto deploy | Zero infra |
| Price floor | Per-second billing since 2026-01-01, min 60s or $0.01 | 3 static sites free; dynamic from $5/mo per service | Pay per call |
| Scaling | Resize/clone/load-balance yourself | Set instance count + size, autoscale | Implicit |
| Best for | Stateful daemons, cron hosts, custom networking, "give me a Linux box" | Web service / API / worker / static site from a repo | Event glue, webhooks |
Rules of thumb:
- **Stateless web service or API from a Git repo → App Platform.** It builds, deploys, gives
you TLS and a URL, and re-deploys on push. No box to patch. **Static frontend → App
Platform static site (free, up to 3)**; don't run a Droplet to serve HTML.
- **You need root, a long-lived stateful daemon, custom ports, or a cron host → Droplet.**
- **A managed Postgres/MySQL/Valkey → always the Managed Database product**, never a DB you
hand-install on a Droplet, unless you have a hard reason.
## doctl setup
`doctl` is the official DO CLI; it drives everything below.
```bash
# macOS
brew install doctl
# Linux: download the release tarball from github.com/digitalocean/doctl/releases, then:
# tar xf doctl-*.tar.gz && sudo mv doctl /usr/local/bin
# Authenticate with a token from cloud.digitalocean.com/account/api/tokens
doctl auth init # pastes a token, validates, stores a context
doctl auth init --context prod # a named context per account/env
doctl auth switch --context prod # switch the active context
doctl account get # verify the token works
```
**Never commit the token.** It is a full-account credential. Keep it in your shell keychain,
a secret manager, or CI secret — never in the repo, never in an app spec. Why: a leaked
`dop_v1_…` token lets anyone create/destroy your whole account.
## App Platform via app spec
App Platform deploys from an **app spec** (YAML or JSON). Treat the spec as the source of
truth, version it, and apply it with doctl.
```yaml
# .do/app.yaml — minimal web service + managed Postgres
name: my-api
region: nyc
services:
- name: web
github:
repo: me/my-api
branch: main
deploy_on_push: true
instance_size_slug: apps-s-1vcpu-1gb # ~$5/mo basic; sizes go up to dedicated
instance_count: 1
http_port: 8080
envs:
- key: NODE_ENV
value: production
scope: RUN_TIME # RUN_TIME | BUILD_TIME | RUN_AND_BUILD_TIME
- key: DATABASE_URL
value: ${db.DATABASE_URL} # injected from the managed DB below
scope: RUN_TIME
- key: API_SIGNING_KEY
value: ${API_SIGNING_KEY}
type: SECRET # encrypted at rest; never plaintext
scope: RUN_TIME
databases:
- name: db
engine: PG
production: true
```
Lifecycle — validate, then create or update:
```bash
doctl apps spec validate .do/app.yaml # structural lint, no deploy
doctl apps create --spec .do/app.yaml # first deploy; prints the app id
doctl apps update <app-id> --spec .do/app.yaml # apply changes (and to roll back: re-apply the prior spec)
doctl apps list # find the id
```
Env scoping that matters:
- `type: SECRET` encrypts the value at rest and hides it in the dashboard. Use it for every
key, token, password. Plain `value:` is readable.
- `scope: BUILD_TIME` for things only the build needs; `RUN_TIME` for runtime; don't leak
build-only secrets into the running container.
- `${db.DATABASE_URL}` (and `${db.HOSTNAME}`, `${db.PORT}`, etc.) are auto-injected when the
service references a `databases:` entry — you never paste the connection string.
Operate:
```bash
doctl apps logs <app-id> --type run --follow # run | build | deploy
doctl apps logs <app-id> <component> --type run # one component
```
**Rollback = re-apply the previous spec** (git-revert `.do/app.yaml` and `doctl apps update`),
or redeploy a prior deployment from the dashboard. There is no magic rollback verb — your
git history of the spec is the rollback mechanism.
For multi-component apps (web + worker + static_site + job + db, health checks, ingress
routes, autoscaling, instance-size table, alerts) see `references/app-spec.md`.
## Droplets
A Droplet is a Linux VPS. Bootstrap it declaratively, lock it down with a *cloud* firewall.
```bash
doctl compute droplet create web-1 \
--region nyc3 --size s-1vcpu-1gb --image ubuntu-24-04-x64 \
--ssh-keys <fingerprint> \
--vpc-uuid <vpc-uuid> \
--user-data-file cloud-init.yaml \
--wait
```
- **Cloud firewall, not just `ufw`.** A cloud firewall filters at DO's edge before traffic
reaches the box, and applies to a tag/group of Droplets. Use it as the real perimeter;
host `ufw` is defense-in-depth, not the only line. Why: a misconfigured `ufw` after a
reboot still leaves the edge firewall protecting you.
- **Reserved IP is free while assigned to a Droplet** (you pay only when it's unassigned).
Assign one so you can re-point it to a replacement Droplet without DNS changes — that's
your failover handle.
- **Snapshots/backups** are your restore path; enable automated backups or snapshot before
risky changes.
- **Per-second billing since 2026-01-01** (min 60s or $0.01): short-lived Droplets are cheap
to spin up and destroy, but a powered-off Droplet still bills for storage — destroy, don't
just power off, to stop charges.
Cloud-init recipes, firewall inbound/outbound rule sets, snapshot cadence + restore,
reserved-IP failover, and the VPC + private-DB layout live in `references/droplet-ops.md`.
## Managed Databases
Use the Managed Database product for Postgres/MySQL/Valkey — DO handles patching, failover,
and backups.
- **Provision sizing:** managed Postgres starts ~$15/mo single node; HA (primary+standby)
from ~$30/mo; read replicas available for read scaling.
- **Connect over the VPC private host, not the public one.** Same-region Droplets and
Managed DBs talk over the VPC with no bandwidth charge and sub-ms latency, and the private
host keeps the DB off the public internet. Add the app's Droplet/App as a **trusted source**
so only it can connect.
- **Connection pooling (PgBouncer) is built in.** Use a pool when many short-lived clients
(serverless, lots of App Platform instances) would otherwise exhaust connections — a
cluster supports up to ~21 pools / up to ~1,000 connections depending on size, and the
pool listens on a **separate pool port** from the raw DB port. Point your app at the pool
connection string, not the raw one.
- For schema design, indexing, query tuning, migrations → that's `../postgresdb/SKILL.md`,
not here. This skill only provisions and wires the cluster.
## Spaces (S3-compatible object storage + CDN)
Spaces is S3-API-compatible object storage with a built-in CDN.
- **$5/mo includes 250 GiB storage + 1 TiB outbound transfer**; inbound is free; the CDN is
included at no extra cost across 200+ edge locations.
- **Spaces keys are NOT your API token.** Generate a separate Spaces access key + secret;
the doctl/API token does not work for S3 operations.
- **Use any S3 SDK or `aws-cli`/`s3cmd`** against the regional endpoint:
```python
# boto3 against DO Spaces (nyc3 region/endpoint)
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://nyc3.digitaloceanspaces.com",
region_name="nyc3",
aws_access_key_id="<SPACES_KEY>", # Spaces key, not the DO API token
aws_secret_access_key="<SPACES_SECRET>",
)
s3.upload_file("photo.jpg", "my-bucket", "photo.jpg", ExtraArgs={"ACL": "public-read"})
```
Serve public assets through the bucket's CDN edge URL; set **CORS** on the bucket if a
browser fetches it cross-origin, and a **lifecycle** rule to expire/transition old objects.
## Basic ops + cost gotchas
- Set **alerts** on the app/DB (deploy failures, CPU, restart count) so you hear about
trouble before users do. App-level dashboards/alerting *strategy* is `../monitoring`.
- **Scale** by editing `instance_count`/`instance_size_slug` in the spec and re-applying;
capacity *strategy* that's host-agnostic is `../scaling`.
- **App Platform data transfer overage is $0.02/GiB**, billed separately from Droplet
transfer — a chatty media app can run up a bill; front heavy static assets with Spaces+CDN.
- **An unassigned reserved IP is billed.** Release reserved IPs you're not using.
## Anti-patterns
| Anti-pattern | Why it bites | Do instead |
| --- | --- | --- |
| Hardcoding a `dop_v1_…` token or DB password as a plain `value:` in the app spec | Spec is in git, value is readable, full-account compromise | `type: SECRET` env, or `${db.*}` injection; token only in `doctl auth` |
| Connecting app→DB over the public host | Public exposure + egress cost + latency | VPC private host + trusted source |
| Running a stateful long-lived daemon as an App Platform service | Components are restartable/stateless; state is lost | Droplet (or a job/worker designed to be stateless) |
| Sizing a dedicated `apps-d-2vcpu-4gb` (~$78/mo) for a hobby app | Paying enterprise rates for toy traffic | Start `apps-s-1vcpu-1gb` (~$5) and scale up on real metrics |
| `ufw` on the Droplet as the only firewall | A bad reboot/config leaves the host open | Cloud firewall at the edge + host `ufw` as defense-in-depth |
| Hand-installing Postgres on a Droplet "to save money" | You now own patching, backups, failover | Managed Database unless you have a hard reason |
| Powering off a Droplet to stop billing | Powered-off Droplets still bill for storage | Snapshot then destroy |
| Leaving a reserved IP unassigned | It's billed when not attached | Release it |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!