One-click deploy platforms
Kiln publishes a container image on every release
(ghcr.io/the-verscienta/kiln_cms:<version>), so a hosting platform can run it
without building anything. This page covers four platforms that can do that,
and the templates in the repository for each:
| Platform | File | How to start | Postgres + pgvector | Media storage |
|---|---|---|---|---|
| Render | render.yaml | Deploy button | Render Postgres 17, works as-is | 1 GB disk |
| Railway | none (Railway keeps templates in its dashboard) | Recipe below | pgvector template (Postgres 18) | Volume |
| Fly | fly.toml | fly launch | Managed Postgres 17, enable vector once | Volume |
| DigitalOcean | .do/app.yaml | doctl apps create | Managed cluster 17, enable vector once | Spaces (S3) |
For a server you run yourself (a VPS, Coolify, Kubernetes), use deploy.md instead. Everything there still applies here; this page covers only what is different on a platform.
Status. The templates follow each platform's documentation as of September 2026. Before this page calls a platform verified, someone has to deploy it end to end: fresh deploy,
/setup, publish a page, view it, restart, and check the page and its images are still there. Until then, treat the steps below as a well-researched first attempt, and report what differs on #1529.
What every template does
- Runs a pinned image tag, never
latest. Upgrading means changing that tag on purpose (see Upgrading). - Sets
SECRET_KEY_BASEandTOKEN_SIGNING_SECRET. Where the platform can generate a long enough value it does; otherwise you paste one fromopenssl rand -base64 64 | tr -d '\n'. Phoenix needs at least 64 bytes forSECRET_KEY_BASE. - Leaves
PHX_HOSTto the platform. WhenPHX_HOSTis unset, Kiln uses the platform's own hostname:RENDER_EXTERNAL_HOSTNAME,RAILWAY_PUBLIC_DOMAIN, or<FLY_APP_NAME>.fly.dev. The DigitalOcean spec bindsPHX_HOSTto${APP_DOMAIN}. When you add a custom domain, setPHX_HOSTto it. It always wins. - Keeps media outside the image. A platform's container filesystem is
wiped on every restart, so the templates either mount a volume at
/app/mediawithKILN_MEDIA_ROOT=/app/media, or use object storage. See Media storage. - Health-checks
/up, which answers 200 once the database is reachable./upand/liveanswer over plain HTTP, because the platform probes the container directly; every other path still redirects to HTTPS. - Creates the first admin at
/setup. Openhttps://<your-host>/setuponce the deploy is live. The wizard only exists while there is no admin.
Render
Click the button above, or create a Blueprint from the repository in Render's dashboard. It creates:
- a web service running the image, on the
0.5c-512mbplan (512 MB); - a Postgres 17 database on the
0.1c-256mbplan, reachable only from Render's private network; - a 1 GB disk at
/app/mediafor uploaded media.
You are asked for one value, SECRET_KEY_BASE. Render's generated values
are 44 characters, too short for Phoenix, so paste one from
openssl rand -base64 64 | tr -d '\n'. TOKEN_SIGNING_SECRET is generated.
Things to know:
- No free tier for this Blueprint. Disks need a paid web plan, and free Render Postgres is deleted after 30 days.
- A disk limits the service to one instance and turns off zero-downtime
deploys: each deploy stops the old instance before starting the new one.
For more than one instance, move media to object storage (
S3_BUCKET) and remove the disk. - Image-backed services never redeploy on their own. Upgrades are a tag change; see Upgrading.
Railway
Railway keeps templates in its dashboard rather than in a repository file, so this is the recipe for building one. Once someone publishes it, a Deploy on Railway button for it can go in the README.
Database. Add Railway's pgvector template. It runs Postgres 18 and creates the
vectorextension for you. Kiln's CI tests Postgres 17, so Postgres 18 is not yet verified. The plain Railway Postgres template has no pgvector and will fail the first migration.App service. Add a service from the Docker image
ghcr.io/the-verscienta/kiln_cms:1.5.1with these variables:Variable Value DATABASE_URL${{Postgres.DATABASE_URL}}(use your database service's name)SECRET_KEY_BASE${{secret(64, "abcdef0123456789")}}TOKEN_SIGNING_SECRET${{secret(64, "abcdef0123456789")}}KILN_MEDIA_ROOT/app/mediaCLIENT_IP_HEADERx-real-ip(per-visitor rate limits; see below)Railway sets
PORT, and Kiln listens on it.Volume. Attach a volume to the app service, mounted at
/app/media.Networking. Generate a public domain. Kiln reads it from
RAILWAY_PUBLIC_DOMAIN.Health check. Set the path to
/up. Railway only checks during a deploy, and sendsHost: healthcheck.railway.app. Kiln answers that probe normally, including withTENANT_STRICT_HOSTon, because health probes are exempt from host checks.
A volume limits the service to one replica and causes a short outage on each redeploy. Railway's free plan caps a volume at 0.5 GB.
Fly
Fly has no deploy button. With flyctl
installed:
fly launch --from https://github.com/The-Verscienta/kiln_cms --copy-config --no-deployfly launch asks for an app name and region and writes them into your copy of
fly.toml. Then create the database:
fly mpg create --pg-major-version 17Open the cluster's Extensions page in the Fly dashboard and enable
vector, then attach it, which sets DATABASE_URL:
fly mpg attach <cluster-id>Set the secrets:
fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')" TOKEN_SIGNING_SECRET="$(openssl rand -base64 64 | tr -d '\n')"Deploy. The first deploy creates the kiln_media volume from fly.toml:
fly deployThings to know:
- Managed Postgres is the main cost. Its smallest plan is about $38 a month, with no free tier. The app machine (1 GB) is a few dollars a month.
DATABASE_URLgoes through PgBouncer. It runs in session mode by default, which Kiln needs: migrations take an advisory lock, and the job queue usesLISTEN. Don't switch the pooler to transaction mode.- The machine stays running (
auto_stop_machines = "off"). Scheduled publishing, webhooks and email run in the background job queue, and a stopped machine does not run it. - A volume belongs to one machine. Keep the app at one machine, or move
media to object storage (
fly storage create, then theS3_*variables in environment-variables.md) before scaling out.
DigitalOcean
App Platform's Deploy button builds from a git branch and offers only a dev
database. Neither fits: building from main ships unreleased code, and a dev
database's user can't create the vector extension, since pgvector is not a
trusted extension. So the spec is deployed with
doctl instead:
Database. Create a managed Postgres 17 cluster named
kiln-dbwith a database namedkiln. Connect asdoadminand runCREATE EXTENSION vector;in that database once.Media. App Platform has no persistent disk, so media goes to Spaces. Create a bucket and an access key.
Secrets. Copy
.do/app.yamland replace everyCHANGE_ME: the two secrets (openssl rand -base64 64 | tr -d '\n'), the bucket name, its public URL, and the key pair. ChangeS3_ENDPOINT_HOSTandregionif you are not in New York.Deploy:
doctl apps create --spec .do/app.yaml
The spec sets S3_ACL=public_read, because Spaces objects are private unless
uploaded public. The smallest App Platform size (512 MB) may be enough for a
small site; the spec uses 1 GB.
Media storage
Without configuration, the Local storage adapter writes under the release's
own priv/uploads, which in the image is
/app/lib/kiln_cms-<version>/priv/uploads. That path changes with every
version, and a platform wipes it on every restart. There are two ways to keep
media:
- A volume at
KILN_MEDIA_ROOT. SettingKILN_MEDIA_ROOT=/app/mediamoves Local storage there: public files to/app/media/public(served at/uploads), private ones to/app/media/private(never served). Mount the platform's volume at/app/media. The in-app backup archives that directory too, so you don't need to setMEDIA_DIRseparately. - Object storage. Set
S3_BUCKETand the rest of theS3_*variables (see environment-variables.md).KILN_MEDIA_ROOTis ignored then. This is the choice for more than one instance, and the only one on DigitalOcean.
Volume ownership. The image runs as nobody (uid 65534). The image's own
/app/media belongs to nobody, but a platform volume mounted over it may
belong to root, and then every upload fails. Kiln checks this at boot, and
if it can't write there it logs "Media storage directory … is not
writable". If you see that, either make the volume writable by uid 65534 or
switch to object storage. Whether each platform's volumes need this is one of
the things the verification drill will confirm.
Client addresses and rate limiting
Kiln's per-IP rate limits (sign-in brute-force protection included) need the
real client address. Behind a proxy on your own server, Kiln takes it from
X-Forwarded-For, but only when the proxy's address is listed in
TRUSTED_PROXIES. None of these four platforms documents the address range
its proxy connects from, so the templates leave TRUSTED_PROXIES unset:
guessing a range risks trusting addresses that aren't the proxy. DigitalOcean
also puts its own ingress address in X-Forwarded-For, so even a correct
TRUSTED_PROXIES wouldn't help there.
Three of the platforms instead write the client's address into a header of
their own, and CLIENT_IP_HEADER tells Kiln to read it (#1548):
| Platform | CLIENT_IP_HEADER | Honoured only while these are set | Template |
|---|---|---|---|
| Fly | fly-client-ip | FLY_APP_NAME and FLY_MACHINE_ID (Fly sets both in every Machine) | set in fly.toml |
| Railway | x-real-ip | RAILWAY_SERVICE_ID and RAILWAY_ENVIRONMENT_ID (Railway sets both) | in the recipe above |
| DigitalOcean | do-connecting-ip | APP_ID, holding an app id (the spec binds it to ${APP_ID}) | set in .do/app.yaml |
| Render | not available |
Why the second column. A header is something any client can send. Trusting
one is safe only when every request reaches the app through the platform's
proxy, which sets or overwrites it. So Kiln honours CLIENT_IP_HEADER only when
the platform's own variables are present too. Copied into a deployment
anywhere else, it is ignored, and Kiln logs one error saying why. On
DigitalOcean the check is weaker: App Platform injects no variable of its own,
so the marker is the ${APP_ID} binding, which only App Platform resolves.
Don't set APP_ID by hand.
How the header is used:
- It wins over
TRUSTED_PROXIESwhen both are set. - A request without the header, or with anything other than one address in it, falls back to the normal rules. That covers health probes, and a request from another app on the platform's private network, which doesn't pass through the proxy. That private path is the one residual risk: Kiln can't tell a header the proxy wrote from one a neighbouring app wrote itself, so another app on the same private network (on Fly, any app in your organization) could pick its own rate-limit bucket. The public internet can't, because it only reaches Kiln through the proxy.
- Live connections see only
x-headers. That is a Phoenix limit: a WebSocket handshake hands the appX-…headers and nothing else. Railway'sX-Real-IPgets through, so on Railway the sign-in form's brute-force limit is per visitor too.Fly-Client-IPanddo-connecting-ipdon't, so on Fly and DigitalOcean the sign-in form (which submits over the live connection) still shares one bucket per deployment. Every HTTP request is per visitor, including/api/auth/sign_inand the other forms.
Render documents neither a proxy range nor a header of its own, so a
Render deployment keeps one shared bucket per deployment, and Kiln logs a
warning about it on the first proxied request. Render sits behind Cloudflare,
which sends True-Client-IP, but Render doesn't document or promise that
header, so Kiln doesn't offer it.
Upgrading
Each template pins an image tag. To upgrade:
- Read the new release's
### Upgrade notesand### Breakingsections in CHANGELOG.md. - Change the tag:
image.urlinrender.yaml(then sync the Blueprint), the image in Railway's service settings,[build] imageinfly.toml(thenfly deploy), ortagin.do/app.yaml(thendoctl apps update <app-id> --spec .do/app.yaml).
Migrations run on boot, as on any deployment.
Which tag to pin
| Tag | Moves | Use it when |
|---|---|---|
1.0.0, 1.0.3 (exact) | Never | Recommended. Every upgrade is a change you made and can roll back. |
1 (floating major, from 1.0.0) | To each new final 1.x release: patches and minors | You want fixes without editing the tag, and accept a minor release, with its migrations, landing on the next restart or redeploy |
latest | To the highest final release, across majors | Trying Kiln out. Never for a site you keep |
1 and latest never point at a release candidate. They also never move
backwards: a security patch for the previous minor (see
SECURITY.md)
is published under its exact tag only. If you stay on the previous minor
during its 90-day window, pin exact versions and move to each patch yourself.
There is no floating minor tag (1.0): the previous minor stops getting fixes
after those 90 days, so a tag that floated on it would go quiet.
Whether a floating tag is re-pulled depends on the platform. Most keep the image they first pulled until you redeploy, so a floating tag saves you the edit, not the redeploy.
Backups
The platform's database backups cover the database. Two things they don't cover:
SECRET_KEY_BASE. Encrypted columns (DKIM keys and other secrets) can't be read without the same value, so keep it wherever you keep the database backups. See backups.md.- Media on a volume. The in-app backup (Backups in the console) archives
KILN_MEDIA_ROOT, but it writes the archive toBACKUP_DIRon the container's own disk, which the platform wipes, unlessBACKUP_RCLONE_REMOTEcopies it off-site. Set that, or snapshot the volume with the platform's tools.