Manual Deploy DigitalOcean

DigitalOcean

elements man deploy/digitalocean

The same flow as AWS (opens in a new tab) on DigitalOcean: sign-in, two questions, the price from the provider, provision, config, deploy.

The one difference is sign-in. DigitalOcean has no browser login for its CLI: the user creates an API token in the control panel and pastes it once. The agent opens the page and asks for the paste, and that is the only technical thing the user does.

1. CLI and Token

doctl is one binary. It installs without admin rights.

Terminal
brew install doctl 2>/dev/null || { mkdir -p ~/.local/bin curl -fsSL https://github.com/digitalocean/doctl/releases/latest/download/doctl-$(uname -s | tr A-Z a-z)-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz | tar -xz -C ~/.local/bin } doctl auth init --context elements # paste the token from https://cloud.digitalocean.com/account/api/tokens doctl account get

2. Confirm the Shape

The same question as on AWS: prototype or production, small or large, region, address. The shapes:

Small Large
Droplet s-1vcpu-1gb s-2vcpu-4gb
Database (production) db-s-1vcpu-1gb, one node db-s-1vcpu-2gb, two nodes

3. Get the Price and Confirm

Droplet sizes come with prices from the CLI. Database prices do not; open https://www.digitalocean.com/pricing/managed-databases in the browser for that line. No price from memory.

Terminal
doctl compute size list --format Slug,Memory,VCPUs,Disk,PriceMonthly | grep -E '^(s-1vcpu-1gb|s-2vcpu-4gb) '

Then the monthly figure and "Continue?". Droplets are capped at the monthly price and include a transfer allowance, so the estimate is the bill.

4. Key and Machine

Terminal
APP=$(cat .elements/id) ssh-keygen -t ed25519 -N "" -f ~/.ssh/elements-$APP -C "elements-$APP" KEY=$(doctl compute ssh-key import "elements-$APP" --public-key-file ~/.ssh/elements-$APP.pub --format ID --no-header) doctl compute droplet create "$APP-production0" --region nyc3 --size s-1vcpu-1gb \ --image ubuntu-24-04-x64 --ssh-keys $KEY --tag-names "elements-$APP,elements-production" \ --wait --format ID,PublicIPv4,PrivateIPv4

Backups stay off: nothing on the droplet is worth backing up, see Machines (opens in a new tab). The firewall is a cloud firewall on the tag, 22, 80 and 443 inbound:

Terminal
doctl compute firewall create --name "elements-$APP" --tag-names "elements-$APP" \ --inbound-rules "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:80,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:443,address:0.0.0.0/0,address:::/0" \ --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0"

5. The Database (Production)

Terminal
DB=$(doctl databases create "elements-$APP" --engine pg --version 17 --region nyc3 \ --size db-s-1vcpu-1gb --num-nodes 1 --wait --format ID --no-header) doctl databases firewalls append $DB --rule tag:elements-$APP doctl databases connection $DB --format Host,Port,User,Password mkdir -p config/certs && doctl databases get-ca $DB > config/certs/db-ca.pem

Large is db-s-1vcpu-2gb with --num-nodes 2. Trusted sources on the database are the droplet's tag, so only the app machine reaches it. Daily backups with seven days of retention are included.

6. Config and the First Deploy

config.jsoc
deploy: { ssh: { user: "root", identityFile: "~/.ssh/elements-<project-id>" }, env: { production: { machines: [ { name: "production0", publicIp: "<public ip>", privateIp: "<private ip>" }, ], }, }, }
config/env/production.env
DB_HOST=<host> DB_PORT=<port> DB_USER=<user> DB_PASSWORD=<password> DB_SSL_ROOT_CERT=config/certs/db-ca.pem

A prototype keeps DB_HOST=127.0.0.1. The domain is as on AWS: their own in deploy.domain with an A record at the droplet's address, or elements domain for an elements.online name (see Domain (opens in a new tab)).

Terminal
elements deploy -json

No errors in the report is a live site. Nothing is verified afterwards; see Machines (opens in a new tab).

Teardown

Everything carries the elements-<project-id> tag: delete the droplet, the firewall, the database, then the key. Confirm with the user first.