Skip to content

DigitalOcean

Deploy Broch on a DigitalOcean Droplet running Docker Compose, with bundled PostgreSQL on attached block storage. Caddy handles automatic wildcard TLS via ACME DNS-01.

The Terraform module lives in broch-io/broch-deploy/terraform/digitalocean — clone the repo, fill in terraform.tfvars, terraform apply, then point your DNS at the reserved IP.

  • DigitalOcean Droplet (Ubuntu 24.04, s-1vcpu-1gb default)
  • Block storage volume (10 GB default) holding the Postgres data, the master key and the database password
  • Reserved IP for a stable DNS target
  • Firewall: HTTPS (443) inbound. SSH (22) only from ssh_allowed_cidrs, which is empty by default
  • cloud-init bootstrap: Docker Compose + Caddy (auto-TLS) + broch + postgres:16-alpine
  • BROCH_MASTER_KEY (the at-rest encryption root), generated on the Droplet at first boot and kept on the volume, so it survives a Droplet recreate. It never leaves your Droplet.

This is the simplest cloud module — a single Droplet. The tradeoff is the obvious one: single VM, no managed services, no failover. To estimate spend, use DigitalOcean’s pricing for your chosen Droplet size.

  • Terraform 1.6+
  • DigitalOcean account with an API token
  • SSH key registered in DigitalOcean — note its fingerprint: ssh_key_fingerprint is required even though SSH starts closed
  • A domain whose DNS is on DigitalOcean, Cloudflare or GoDaddy (dns_provider = digitalocean, cloudflare, the default, or godaddy), and an API token that can edit its zone (dns_api_token). Route 53 isn’t supported on this module; use the AWS appliance instead.
  • Wildcard domain & DNS planned (you point it at the reserved IP after the first apply)
  • Identity provider app registration created
  • License — activated in-app after first sign-in (nothing to set before deploy)

This deployment uses Caddy with ACME DNS-01 for automatic wildcard TLS — no manual certificate management. You provide your DNS provider’s API token at apply time; Caddy handles certificate issuance and renewal in the background.

See TLS Certificates for the broader certificate story.

Terminal window
git clone https://github.com/broch-io/broch-deploy.git
cd broch-deploy/terraform/digitalocean
cp terraform.tfvars.example terraform.tfvars
$EDITOR terraform.tfvars
terraform init
terraform apply # 3-4 min — Droplet + cloud-init + image pull

terraform plan refuses settings Broch would refuse at startup: an unknown auth_provider, a missing client ID or secret, a blank wildcard_hostname, or a value your provider needs (Auth0: auth_domain and auth_audience; Okta: auth_domain; Entra ID: auth_tenant_id, and for Entra External ID also auth_instance; generic OIDC: auth_authority). It also refuses any auth or hostname value containing a single quote or a line break, or ending in a backslash. On this module the admin role variable is admin_roles, default broch_admin.

After apply:

Terminal window
echo "Reserved IP: $(terraform output -raw droplet_ip)"
# Create two A records (a wildcard doesn't cover the bare name):
# broch.{your-domain} → <reserved-ip>
# *.broch.{your-domain} → <reserved-ip>
# Wait for Caddy to issue certs (~30-60s once DNS propagates), then verify:
curl -fsS "$(terraform output -raw broch_url)/healthz"

Then sign in and finish setup: see Self-Managed Deployment → After You Install.

SSH is closed by default. To open it, set ssh_allowed_cidrs = ["<your-ip>/32"] in terraform.tfvars and run terraform apply; only the firewall changes.

Terminal window
$(terraform output -raw ssh_command)
# → ssh root@<reserved-ip>

The Droplet runs Docker Compose at /opt/broch/. docker compose ps shows the running services; docker compose logs broch-server tails server output.

Update image_tag in terraform.tfvars and re-apply. This recreates the Droplet (cloud-init re-runs with the new tag). The block storage volume, with the database, the master key and the database password, is detached and reattached, so your data and sign-ins survive. Expect ~3–4 min of downtime.

Leave image_tag unset and it follows the release your broch-deploy checkout pins: updating the checkout and re-applying upgrades too.

These commands use SSH, so open it first (see SSH Access).

Terminal window
# Snapshot Postgres to your local machine
ssh root@$(terraform output -raw droplet_ip) \
"cd /opt/broch && docker compose exec -T postgres pg_dump -U broch brochdb" \
> broch-$(date +%Y%m%d).sql
# Restore: stop broch, recreate the database, load the dump, start broch
ssh root@$(terraform output -raw droplet_ip) \
"cd /opt/broch && docker compose stop broch-server && docker compose exec -T postgres dropdb -U broch brochdb && docker compose exec -T postgres createdb -U broch -O broch brochdb"
ssh root@$(terraform output -raw droplet_ip) \
"cd /opt/broch && docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U broch -d brochdb" < broch-20260525.sql
ssh root@$(terraform output -raw droplet_ip) "cd /opt/broch && docker compose up -d"

Snapshots of the block storage volume in the DigitalOcean console also work: the volume holds the database, the master key and the database password. Caddy re-issues its certificate on its own.

  • Single Droplet — the simplest cloud Broch. See Scaling.
  • Bundled Postgres — one less service to operate. When you need point-in-time recovery or a standby replica, use DO Managed Databases instead and switch to the docker-compose with-postgres-external variant on the same Droplet.
  • s-1vcpu-1gb default — a minimal size for evaluation. Bump droplet_size for real load.
  • Single AZ — DO Droplets are AZ-bound and Broch runs as one instance. Recover from a volume snapshot; there’s no failover.
  • SSH closed by default — set ssh_allowed_cidrs to your bastion or VPN range for break-glass access.
  • No automated DB backups — configure your own via cron + DO Spaces or external storage.

See the module README for the full list.

The data volume carries prevent_destroy, so terraform destroy stops at plan, as does any change that would replace the volume, such as a new region. Export the database or snapshot the volume first, then:

Terminal window
$EDITOR main.tf # delete `prevent_destroy = true` on digitalocean_volume.broch_data
terraform destroy