Skip to content

Terraform ​

All DigitalOcean infrastructure lives in infra/terraform/. Workloads do not — those are Helm releases deployed separately (see Kubernetes).

Files ​

FileContains
provider.tfProvider versions, DigitalOcean/Kubernetes/Helm providers, backend config
variables.tfEvery input variable with defaults
main.tfVPC, DOKS, database, Spaces, CDN, registry, firewalls, K8s secrets, project
ingress.tfnginx-ingress Helm release
cert-manager.tfcert-manager Helm release and ClusterIssuers
dns.tfDomain and DNS records
livekit.tfLiveKit droplet and firewall
database-grants.tfPostgreSQL schema grants for the app user
staging.tfStaging: the staging namespace, *.staging DNS records, the staging media and KYC buckets with bucket-scoped keys, and the Secrets built from them
outputs.tfConnection details and helper commands
MakefileConvenience targets
validate-setup.shPre-flight checks

Getting started ​

bash
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 cluster

make help lists every target. make all chains init → validate → plan → apply → kubeconfig.

Key variables ​

VariableDefaultNotes
project_nameplaypalzPrefixes resource names
environmentproduction
regionnyc3
kubernetes_version_prefix1.34.Minor line only; the patch slug is resolved at plan time, because DigitalOcean retires individual slugs
node_pool_sizes-4vcpu-8gb
node_pool_count3
node_pool_min_nodes / max_nodes2 / 10Autoscaling bounds
enable_auto_upgradetruePatch upgrades during the maintenance window
enable_surge_upgradetrueAdds a node before draining one
db_engine / db_versionpg / 15
db_sizedb-s-2vcpu-4gb
db_node_count1Raise to 2+ for HA
spaces_bucket_nameplaypalz-media
enable_spaces_cdntrue
kyc_bucket_nameplaypalz-kycPrivate bucket for creator ID images
vpc_ip_range10.10.0.0/16
domain_nameplaypalz.gg
manage_dnsfalseWhether Terraform owns DNS records
enable_wildcard_dnsfalse
livekit_droplet_sizes-4vcpu-8gb

What it provisions ​

Networking and compute ​

  • VPC 10.10.0.0/16 isolating 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 public without them, and Prisma fails with permission 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 a digitalocean_spaces_key scoped to it alone. It has prevent_destroy, so terraform destroy fails 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:

SecretContents
database-credentialsDATABASE_URL, host, port, user, password, database name
spaces-credentialsAccess key, secret, region, endpoint, URL, bucket
kyc-storageKYC_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 ​

bash
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 versions

Avoid 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 apply tries 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-state is the only safety net, and it is manual.

Migrate to a remote backend. DigitalOcean Spaces works as an S3-compatible backend:

hcl
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 ​

  1. Edit the .tf file.
  2. make plan and read it — confirm nothing is being replaced that you did not intend.
  3. make apply.
  4. 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 ​

bash
make destroy

Irreversible: 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.

Internal documentation — PlayPalz platform