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.
What Gets Provisioned
Section titled “What Gets Provisioned”- DigitalOcean Droplet (Ubuntu 24.04,
s-1vcpu-1gbdefault) - 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.
Prerequisites
Section titled “Prerequisites”- Terraform 1.6+
- DigitalOcean account with an API token
- SSH key registered in DigitalOcean — note its fingerprint:
ssh_key_fingerprintis required even though SSH starts closed - A domain whose DNS is on DigitalOcean, Cloudflare or GoDaddy (
dns_provider=digitalocean,cloudflare, the default, orgodaddy), 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.
git clone https://github.com/broch-io/broch-deploy.gitcd broch-deploy/terraform/digitalocean
cp terraform.tfvars.example terraform.tfvars$EDITOR terraform.tfvars
terraform initterraform apply # 3-4 min — Droplet + cloud-init + image pullterraform 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:
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 Access
Section titled “SSH Access”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.
$(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.
Upgrading
Section titled “Upgrading”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.
Backup
Section titled “Backup”These commands use SSH, so open it first (see SSH Access).
# Snapshot Postgres to your local machinessh 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 brochssh 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.sqlssh 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.
Tradeoffs: What the Default Module Is Not
Section titled “Tradeoffs: What the Default Module Is Not”- 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-externalvariant on the same Droplet. s-1vcpu-1gbdefault — a minimal size for evaluation. Bumpdroplet_sizefor 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_cidrsto 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.
Teardown
Section titled “Teardown”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:
$EDITOR main.tf # delete `prevent_destroy = true` on digitalocean_volume.broch_dataterraform destroy