Deploying with a PaaS
Self-hosting gets you a running instance with two docker compose commands. This page is the other shape: a managed deployment on a single
server, where a PaaS layer — Coolify in this example, but Dokploy and CapRover
work the same way — handles TLS, restarts, secrets and image pulls.
It is written for the topology infra/docker/compose.services.yml describes: one
Node container, three static ones, one Postgres, several domains
(ADR-0023). Everything
here applies to the single-domain stack too; you simply use fewer of it.
Before the server
Have these ready. Each one is a thing that stops a deployment halfway if it is missing:
- DNS, one A record per hostname, all pointing at the server. TLS is issued per hostname, so a record that does not resolve yet is a certificate that cannot be obtained.
- A registry token. The published images are private
(ADR-0022), so
the PaaS needs read access — on GitHub, a personal access token with
read:packagesand nothing else. - Object storage, if you want attachments off the machine. See Configuration → Storage, including the three bucket properties that cannot be changed after creation.
- An SMTP relay. Without one there is no password reset and no invitation mail. Configuration → Email covers relaying through a mailbox you already have, which is usually the fastest working answer.
1. Install the PaaS
Coolify installs as root, with one command from its own documentation. It brings Docker if the machine has none. Afterwards its interface answers on port 8000 — create the admin account immediately, because until you do, anybody who finds the port can.
Coolify updates itself from its own settings screen; there is no distribution package for it. On a production machine, leave auto-update OFF: an update that runs at night without you is unpleasant exactly when it goes wrong.
2. Close the ports — before anything is deployed
This is the step that is easy to skip and expensive to skip.
Allow SSH, 80 and 443 from the internet. Nothing else. Restrict the PaaS interface port to your own address, or reach it through an SSH tunnel.
Check which port SSH is actually on before enabling anything. A hardened server often moves it, and a firewall enabled without that port open locks you out of the machine you are configuring. Keep a second session open while you switch it on.
A host firewall does not stop Docker. This is the part that catches experienced people. Docker writes its port forwards into iptables'
DOCKERchain, which is evaluated BEFORE ufw's rules — so a published container port is reachable from the internet even whileufw statusreports it closed.Two answers, and the shipped compose file uses the second:
- A firewall outside the machine. Most providers offer one; it sits in front of the host and cannot be bypassed from inside it. This is the stronger control.
- Bind published ports to localhost.
compose.services.ymlmaps every port as127.0.0.1:<host>:<container>, so the services stay reachable forcurlon the box and unreachable from anywhere else regardless of what any firewall thinks. The PaaS proxy does not need the host ports — it reaches the containers over the Docker network.Use both. The second one holds when somebody forgets the first.
3. Give the PaaS the registry token
In Coolify: Settings → Docker Registries, host ghcr.io, your username, the
token as the password. Without it every pull fails with a message about the
manifest not being found, which reads like a wrong tag rather than a missing
credential — the most misleading error in this whole process.
4. Create the project from the compose file
Paste infra/docker/compose.services.yml in as a Docker Compose resource. The
PaaS parses the services and lets you attach a domain to each one. Map them to the
hostnames whose DNS you set up, and let it issue certificates.
A domain you own but have nothing to serve yet — a second product's marketing site that does not exist — is better pointed at something with a redirect than left resolving to nothing.
5. Environment
Every setting goes in one place, and the compose files pass the whole lot into the container (Configuration has the complete list). The ones a multi-domain deployment cannot work without:
AUTH_SECRET= # openssl rand -hex 32
POSTGRES_PASSWORD= # openssl rand -hex 16
BASE_URL=https://app.example.com
EXTRA_ORIGINS=https://app.other-product.example
TRUST_PROXY=172.16.0.0/12
EXTRA_ORIGINS is the one people miss, and the failure is specific: the second
product's sign-in answers 403 INVALID_ORIGIN while everything else works, because
the auth layer only trusts origins it was told about.
TRUST_PROXY matters more here than in a bare compose install, because there is
always a proxy in front and sometimes two. Without it the sign-in rate limiter
counts the proxy as the only client — ten failed sign-ins by anyone lock out
everyone.
6. Deploy a TAG, not a branch
The image tags default to main, which is right for a laptop and wrong for a
server. main moves with every push, and the release workflow runs in parallel
with CI rather than after it — an image can exist for a commit whose tests
subsequently fail.
Cut a version tag, let the release build it, and pin that:
BASALT_IMAGE_TAG=v1.2.3
LITHIC_IMAGE_TAG=v1.2.3
MARKETING_IMAGE_TAG=v1.2.3
DOCS_IMAGE_TAG=v1.2.3
All images of one commit share a sha- tag as well, so a deployment can always be
named exactly afterwards.
7. Smoke test, in this order
The order is deliberate: each step needs less to be true than the one after it, so the first failure tells you where the problem is.
- The documentation site. It needs nothing but nginx, so a failure here is DNS or TLS, never the application.
- The marketing site. Same, plus that the right image went to the right domain.
- Register an account on the app. This is the first step that needs the database and the migrations.
- Upload an image into a page. The first real write to object storage — the step that proves the endpoint resolves and the key has permission. Nothing before this touches the bucket.
- Request a password reset. The first real mail. If nothing arrives: spam folder first, then the app container's logs, which say whether the message was queued and what the relay answered.
- Sign in to the second product with the same account. This exercises
EXTRA_ORIGINSand the/apiproxy together — the two things that are invisible until a browser tries them.
Steps 4 and 5 are the ones that can only fail in the real environment. Everything else has been exercised by CI.
When something is wrong
Certificates do not issue. The hostname does not resolve to this server yet, or port 80 is closed. The issuer needs 80 even when the site will only ever serve 443.
The editor sits on "Connecting…" forever, everything else works. A WebSocket upgrade is being dropped. Basalt uses two socket planes, and both go through every proxy in the chain — see Operations for the headers each one needs.
Sign-in fails on one domain only. EXTRA_ORIGINS does not name it. The
server's log records the refusal; the browser shows a generic failure.
Images 404 after a redeploy. Attachments were on the local driver without a
mounted volume. Operations → Attachments has the
check that actually detects this — and the reason ls does not.
Everything is slow after a while, nothing in the logs. Check disk. A server whose disk is full fails in ways that look like anything except a full disk.