DigitalOcean App Platform
The deployment this project ships by default: a container image built locally
and pushed to GHCR, with infrastructure in terraform/ — a thin root over the
shared module
terraform-digitalocean-symfony-app,
pinned to a tag in terraform/main.tf.
The health check starts at GET /login and moves to GET /healthz once the
database is reachable — see step 6 below. /healthz queries the database, and
on the first apply the app boots before trusted sources attach, so pointing at
it up front fails the deploy.
Prerequisites
-
doctl, authenticated against the DigitalOcean account. -
terraform. -
Docker with
buildx— App Platform runs amd64, so the image must be cross-built from an Apple Silicon workstation. -
A container registry you can push to. The defaults name this project’s own package,
ghcr.io/ubermuda/loupe:prod, which nobody else can write to. Point the tooling at yours in both places, or the image you push is not the image App Platform pulls:Terminal window export LOUPE_PROD_IMAGE=ghcr.io/you/loupe:prod # just build-prod / push-prod / deployand set
registry,image_repository,image_tag(andregistry_type, if not GHCR) interraform.tfvarsto match. -
A pull token for that registry: for GHCR, a GitHub PAT with
read:packages, supplied to Terraform as"username:PAT". App Platform needs it to pull a private image. -
A Postgres cluster — bring your own, or let Terraform create one. Either way
regionhas no default and must be set.Bring your own (what this deployment does): Terraform creates a database and a user on a cluster that already exists, so
db_cluster_namehas no default either andterraform applyfails until you supply it.Terminal window doctl databases create loupe-db --engine pg --region tor1doctl databases list # the Name column is db_cluster_nameOr have the module create a dedicated one — set
create_db_cluster = trueand leavedb_cluster_nameunset. The module creates a cluster namedloupe-db, sizes it fromdb_cluster_size(defaultdb-s-1vcpu-1gb) anddb_cluster_node_count(default1), and manages its trusted sources — which removes thejust tf-db-bootstrapfirewall step below. The cluster carriesprevent_destroy, soterraform destroyrefuses and so does flipping the flag back;terraform state rmis the deliberate override.db_cluster_regionis a datacenter slug (tor1), not App Platform’s metro slug (tor). They are different namespaces. Passing the wrong one plans cleanly and fails at apply, so the module validates its shape and warns when the two are not colocated. -
A Spaces access key pair, generated under “Spaces Keys” in the control panel. Spaces authenticates with S3-style credentials rather than the API token, so Terraform needs both to create the export bucket. Export them as
SPACES_ACCESS_KEY_ID/SPACES_SECRET_ACCESS_KEY. (Not needed if you setcreate_export_bucket = falseand bring your own S3 bucket.)
First deploy
The first deploy has two steps that cannot be Terraformed, because a firewall resource would cut off the sibling apps sharing the cluster.
# 1. Build and push the amd64 image. Export LOUPE_PROD_IMAGE first unless you# are pushing to this project's own package.just push-prod
# 2. Create the infrastructure, including the export bucket. Leave# enable_predeploy_migrations OFF for now — the migration job cannot reach# the database until step 3.just tf-initjust tf-apply
# 3. One-time database bootstrap: add this app plus your IP to the cluster's# trusted sources, and GRANT schema privileges to the app's user.just tf-db-bootstrap
# 4. Run migrations once, by hand. release.sh needs exactly three values.# There is no env-file template for this: docker/compose/prod.env.example# belongs to the single-host stack, which has no DATABASE_URL at all —# prod.yaml assembles one from POSTGRES_* against a `database` container# that does not exist here. Build the DSN from the managed cluster instead.# The connection string below is the cluster's DEFAULT user and database, so# substitute this app's own, which `just tf-output` reports as db_user and# db_name.doctl databases connection "$(just tf-output -raw db_cluster_id)" --format URI
docker run --rm \ -e APP_ENV=prod \ -e APP_SECRET="$TF_VAR_app_secret" \ -e DATABASE_URL="postgresql://<db_user>:<password>@<host>:<port>/<db_name>?sslmode=require&serverVersion=16" \ "${LOUPE_PROD_IMAGE:-ghcr.io/ubermuda/loupe:prod}" docker/prod/release.sh
# 5. Turn on automated migrations for every deploy afterwards:# set `enable_predeploy_migrations = true` in terraform.tfvars, thenjust tf-apply
# 6. Now that the database is reachable, move the health check onto /healthz:# set `health_check_path = "/healthz"` in terraform.tfvars, thenjust tf-applyAfter the first apply, note the assigned *.ondigitalocean.app URL and set
default_uri to it in terraform.tfvars (or set custom_domain there and let
the module derive it). Set it in terraform.tfvars, not in terraform/main.tf,
where both are already wired to those variables. Without that, CLI- and worker-generated absolute URLs —
password reset links, data-export download links — point at the wrong host.
Routine deploys
just deploy # build amd64, push, create a deployment, wait for it to go livejust logs-prod # tail production logsjust shell-prod # shell into the prod image locally, for build debuggingWith enable_predeploy_migrations = true, migrations run as a PRE_DEPLOY job
before the new containers roll.
Infrastructure it creates
| Resource | What it is |
|---|---|
| Web + worker services | Two components from the same image; the worker is enable_worker / worker_command in terraform/main.tf. |
| Postgres | A per-app database and user on a managed cluster you already own, named by db_cluster_name. Terraform creates the database and the user; it never creates the cluster. |
| Export bucket | A DigitalOcean Spaces bucket plus a bucket-scoped access key, created by terraform/spaces.tf and wired in as ordinary EXPORT_STORAGE_* settings. |
| Mercure hub | A second service in the same app, run by the shared module when mercure_jwt_secret is set. |
Rolling back
App Platform keeps previous deployments. Roll back through the DigitalOcean
console, or re-push a known-good image tag and deploy again. Note that the
default image_tag is a fixed prod — there is no per-release tag, so “the
previous image” is only recoverable through App Platform’s own deployment
history. Building with
LOUPE_PROD_IMAGE=<registry>/loupe:$(git rev-parse --short HEAD) and setting
image_tag to match would make rollback a one-command operation.