Terraform
All DigitalOcean infrastructure lives in infra/terraform/. Workloads do not — those are Helm releases deployed separately (see Kubernetes).
Files
| File | Contains |
|---|---|
provider.tf | Provider versions, DigitalOcean/Kubernetes/Helm providers, backend config |
variables.tf | Every input variable with defaults |
main.tf | VPC, DOKS, database, Spaces, CDN, registry, firewalls, K8s secrets, project |
ingress.tf | nginx-ingress Helm release |
cert-manager.tf | cert-manager Helm release and ClusterIssuers |
dns.tf | Domain and DNS records |
livekit.tf | LiveKit droplet and firewall |
database-grants.tf | PostgreSQL schema grants for the app user |
staging.tf | Staging: the staging namespace, *.staging DNS records, the staging media and KYC buckets with bucket-scoped keys, and the Secrets built from them |
outputs.tf | Connection details and helper commands |
Makefile | Convenience targets |
validate-setup.sh | Pre-flight checks |
Getting started
cd infra/terraform
cp terraform.tfvars.example terraform.tfvars
# fill in do_token, spaces_access_id, spaces_secret_key, letsencrypt_email,
# livekit_ssh_key_fingerprint
./validate-setup.sh # checks tools, auth, config, and estimates cost
make init
make plan
make apply
make kubeconfig # configure kubectl against the new clustermake help lists every target. make all chains init → validate → plan → apply → kubeconfig.
Key variables
| Variable | Default | Notes |
|---|---|---|
project_name | playpalz | Prefixes resource names |
environment | production | |
region | nyc3 | |
kubernetes_version_prefix | 1.34. | Minor line only; the patch slug is resolved at plan time, because DigitalOcean retires individual slugs |
node_pool_size | s-4vcpu-8gb | |
node_pool_count | 3 | |
node_pool_min_nodes / max_nodes | 2 / 10 | Autoscaling bounds |
enable_auto_upgrade | true | Patch upgrades during the maintenance window |
enable_surge_upgrade | true | Adds a node before draining one |
db_engine / db_version | pg / 15 | |
db_size | db-s-2vcpu-4gb | |
db_node_count | 1 | Raise to 2+ for HA |
spaces_bucket_name | playpalz-media | |
enable_spaces_cdn | true | |
kyc_bucket_name | playpalz-kyc | Private bucket for creator ID images |
vpc_ip_range | 10.10.0.0/16 | |
domain_name | playpalz.gg | |
manage_dns | false | Whether Terraform owns DNS records |
enable_wildcard_dns | false | |
livekit_droplet_size | s-4vcpu-8gb |
What it provisions
Networking and compute
- VPC
10.10.0.0/16isolating all resources - DOKS cluster with an autoscaling node pool
- LiveKit droplet with its own firewall, outside the cluster because WebRTC needs a wide UDP range and host networking
Data
- Managed PostgreSQL 15, plus a dedicated database and application user
- Database firewall restricting access to the Kubernetes cluster — the database is not reachable from the internet, including from an operator's laptop
- Schema grants for the application user, applied as a Job inside the cluster. PostgreSQL 15 will not let a DigitalOcean "normal" user create tables in
publicwithout them, and Prisma fails withpermission denied for schema public— see Schema permissions - Spaces bucket with a CORS configuration and an optional CDN
- KYC bucket (
playpalz-kyc): private, with no CDN, no CORS and no versioning, plus adigitalocean_spaces_keyscoped to it alone. It hasprevent_destroy, soterraform destroyfails until you remove that guard on purpose
Redis is deliberately not here — it runs in-cluster from the playpalz-platform Helm release (infra/helm/values/production/platform.yaml).
Platform services
- nginx-ingress via Helm, with explicit resource requests and limits
- cert-manager via Helm, with resources set for the controller, webhook, and cainjector
Credentials
Terraform writes three Kubernetes secrets directly, so the manifests can reference them without anyone copying values by hand:
| Secret | Contents |
|---|---|
database-credentials | DATABASE_URL, host, port, user, password, database name |
spaces-credentials | Access key, secret, region, endpoint, URL, bucket |
kyc-storage | KYC_SPACES_*: the KYC bucket and its scoped key. The encryption key is not here; it lives in sealed api-secrets so it never enters state |
DATABASE_URL is assembled from a db_url_scheme local rather than var.db_engine directly: DigitalOcean's engine slug for Postgres is pg, which is not a valid URL scheme, and Prisma rejects it with P1013: the scheme is not recognized.
Everything else is a sealed secret — see Secrets Management.
Useful Makefile targets
make plan # generate and show an execution plan
make apply # apply, with confirmation
make output # all outputs
make kubeconfig # configure kubectl
make registry-login # doctl registry login
make check-cluster # cluster status
make check-db # database status
make cost # cost estimate
make backup-state # copy the state file
make check-versions # available Kubernetes versionsAvoid make apply-auto and make quick-deploy — both skip confirmation, and the Makefile's own help text flags them as dangerous.
State management
State is local only. infra/terraform/.gitignore correctly excludes *.tfstate, *.tfvars, and .terraform/, and the remote backend in provider.tf is commented out — so terraform.tfstate exists on whichever machine last ran apply and nowhere else.
Local state is a single point of failure
- One laptop owns production. Lose that file and Terraform no longer knows what exists; the next
applytries to recreate everything. - No locking. Two people applying concurrently corrupt state.
- Secrets in plaintext. State contains database passwords, Spaces keys, and generated registry credentials. It is not in git, but it is sitting unencrypted on a developer machine.
make backup-stateis the only safety net, and it is manual.
Migrate to a remote backend. DigitalOcean Spaces works as an S3-compatible backend:
terraform {
backend "s3" {
endpoint = "https://nyc3.digitaloceanspaces.com"
bucket = "playpalz-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1" # required but ignored by Spaces
skip_credentials_validation = true
skip_metadata_api_check = true
}
}Migrating gives you locking, encryption at rest, and a state file that does not live on one person's machine.
Never commit tfplan
make plan saves the plan to infra/terraform/tfplan. A plan file embeds a full state snapshot and the variable values: the database passwords, the Spaces keys and the DigitalOcean token. It's gitignored.
It was committed in the past (078cdcc, c4ad8ee), so those versions are still in git history. Anyone with read access to the repo can recover the database passwords, the Spaces keys, the DO token and the registry credentials from them. The repo is private to the team, and the decision (2026-10-30) was not to rotate them. If the repo is ever shared beyond the team, made public, or a team member with access leaves, rotate them all first and remove the file from history with git filter-repo.
Making changes
- Edit the
.tffile. make planand read it — confirm nothing is being replaced that you did not intend.make apply.- Update this page if you changed the shape of the infrastructure.
Some changes destroy and recreate
Watch the plan for -/+ destroy and then create replacement. Changing the VPC IP range, the database engine or version, or the cluster region will replace the resource. For the database that means data loss unless you restore from a snapshot.
Destroying
make destroyIrreversible: the cluster, the database and its data, and the Spaces bucket and its contents all go. Take a database snapshot and confirm you have the media backed up first. There is essentially no legitimate reason to run this against production.
