Deploying a Grit App: Dokploy, Coolify, Render & Fly.io
Four self-contained deployment guides against one docker-compose and monorepo layout. Dokploy and Coolify run your Compose file almost as-is on a VPS; Render and Fly.io don't run Compose at all and need a translated config. Pick the platform you're on — each section is independent.
Four self-contained guides, all against the same docker-compose.prod.yml and
monorepo layout (apps/api, apps/admin, apps/web, Postgres, Redis,
migrate job) used in the Railway guide. Two of these platforms
(Dokploy, Coolify) run your Compose file almost as-is; the other two
(Render, Fly.io) don't run Compose at all and need a translated
config, the same way Railway did. Jump to the section you need — each is
independent and repeats what it needs from the Compose file.
Your docker-compose.prod.yml is already written for Dokploy (see its
header comments), so this is the least amount of translation of the four —
mostly server setup, DNS, and pasting environment variables.
Spin up a VPS (2 vCPU / 4 GB RAM minimum for four app containers +
Postgres + Redis; more if traffic is expected). Ubuntu 22.04/24.04 is the
best-supported OS for Dokploy's install script.
Point your registrar/DNS provider's records at the VPS's public IP —
same three hosts as before:
sentex.gritcms.com → <VPS public IP>admin.sentex.gritcms.com → <VPS public IP>api.sentex.gritcms.com → <VPS public IP>
These stay A records (not CNAMEs) because Dokploy's Traefik
terminates TLS directly on your server's IP — exactly what the Compose
file's header comments describe.
SSH into the server as root (or a user with sudo).
Wait for it to finish — it installs Docker if missing, starts Dokploy's
own containers, and creates the dokploy-network Docker network that
your Compose file's networks.dokploy-network.external: true expects.
Open http://<VPS public IP>:3000 in a browser and create your admin
account on first load.
(Recommended) Under Settings → Server, point a domain at the Dokploy
dashboard itself and enable HTTPS for it, so you're not managing
infrastructure over plain HTTP long-term. This is separate from your
three app domains.
In Dokploy, go to Settings → Git Providers → GitHub.
Follow the prompts to install the Dokploy GitHub App on your account/org
and grant it access to the sentex repository. (SSH deploy keys are the
alternative if you'd rather not install a GitHub App — see Dokploy's
Providers docs for that flow.)
In the Dokploy dashboard, click Create Project, name it sentex.
Inside the project, click Create Service → Compose.
Under Source, choose GitHub, select the sentex repository and
the branch you're deploying (e.g. main).
Set Compose Path to:
docker-compose.prod.yml
Leave the network settings alone — since your file already declares
networks: dokploy-network: external: true plus its own internal
sentex bridge network, Dokploy will attach correctly without any
extra configuration.
Paste in the full contents of your .env.production file (one
KEY=VALUE per line) — Dokploy writes this to a .env file next to
your compose file on the server and uses it to interpolate every
${VARIABLE} reference in docker-compose.prod.yml, for both build
args (NEXT_PUBLIC_API_URL, THEME, etc.) and runtime environment
(POSTGRES_PASSWORD, APP_URL, CORS_ORIGINS, R2 credentials, and so
on). This is a direct match for env_file: [.env] already declared for
migrate and api in your Compose file.
Double-check WEB_DOMAIN, ADMIN_DOMAIN, API_DOMAIN are set to the
exact hosts from step 1.1 — these are baked into the frontend bundles at
build time and drive the Traefik Host() rules already written into
your Compose file's labels.
Build migrate, api, admin, web from their Dockerfiles.
Start postgres and redis and wait for their healthchecks.
Run migrate (./migrate && ./seed) to completion — this works
unmodified because Dokploy runs a real docker compose up, and plain
Docker Compose (unlike Railway) natively understands
depends_on: condition: service_completed_successfully and
restart: "no". You don't need any pre-deploy-command workaround
here.
Start api, then admin and web once api has started.
Watch the deployment logs in the Dokploy UI. Confirm migrate exits 0
before api starts.
After roughly 10 seconds, Traefik should finish provisioning Let's
Encrypt certificates for the three Host() rules already defined in
your Compose labels (sentex-api, sentex-admin, sentex-web
routers). No separate "Domains" configuration step is required in the
UI, since the labels already declare everything — that's what the
Compose file's own header comments mean by "routing works" this way.
In the Compose service's General tab, enable the GitHub webhook
("Auto Deploy" / deploy-on-push) so pushes to your branch redeploy
automatically — Dokploy re-clones the repo, re-reads the .env it
generated, and reruns docker compose up -d --build.
migrate/seed reruns every deploy; it's already idempotent per the
Compose file's own comments, so this is safe.
Coolify also runs your Compose file close to as-is (it literally runs
docker compose under the hood), but it manages its own reverse-proxy
network and strongly warns against custom Compose networks. You'll make a
small, mechanical edit to docker-compose.prod.yml for this platform —
everything else (services, builds, volumes, the migrate job) stays
exactly as written.
Spin up a VPS (same sizing guidance as Dokploy). Ubuntu 24.04 or Debian
13 are the best-supported targets.
Point the same three DNS A records at this VPS's IP (a different VPS
than Dokploy's, obviously, if you're comparing platforms — don't point
both at the same IP at the same time).
Open http://<VPS public IP>:8000, create your admin account, and
(recommended) attach a domain + HTTPS to the Coolify dashboard itself
under server settings.
Create docker-compose.coolify.yml (or a coolify branch — whatever fits
your workflow) with two changes from docker-compose.prod.yml:
Remove the networks: block at every service, and delete the
top-level networks: section entirely (both the sentex bridge and
the dokploy-network: external: true reference). Coolify creates its
own isolated bridge network per Compose stack and attaches its own
Traefik to it automatically — defining custom networks alongside that
causes exactly the kind of intermittent 504/unreachable behavior
Coolify's docs specifically warn about, because your containers would
sit on two networks at once and Traefik might pick the wrong one.
Remove the labels: blocks (the traefik.* labels) from api,
admin, and web. You'll set domains through Coolify's UI instead in
Part 2.4 — its proxy is still Traefik, but its label names/entrypoint
names don't necessarily match Dokploy's, so hand-rolled labels are more
likely to conflict with what Coolify generates than to help.
Everything else — build:, image:, environment:, env_file:,
volumes:, depends_on:, healthcheck:, command:, restart: — stays
identical. In particular, leave the migrate service and its
depends_on: condition: service_completed_successfully exactly as-is —
Coolify runs real Docker Compose, so this ordering guarantee works without
any translation, the same as on Dokploy.
Optionally, mark migrate as excluded from Coolify's aggregate
healthchecks, since it's meant to exit rather than stay running:
Coolify reads your Compose file's services and lets you assign a domain to
each one directly — no labels needed since you removed them in Part 2.2.
On the resource's configuration screen, find the domain field for each
service and set:
api → https://api.sentex.gritcms.com:8080 (append :8080 because
that's the container port api listens on — Coolify's proxy still
serves the public side on the normal HTTPS port; the :8080 just
tells it where to send traffic internally)
admin → https://admin.sentex.gritcms.com:3000
web → https://sentex.gritcms.com:3000
Leave postgres and redis with no domain assigned — without a
domain or a ports: mapping, Coolify keeps a service private and
reachable only over the internal network at http://postgres:5432 /
http://redis:6379-style hostnames (i.e., exactly the plain service-name
DNS your Compose file's POSTGRES_HOST=postgres / REDIS_URL values
already assume).
Coolify auto-detects every ${VARIABLE} referenced in your Compose file
(in environment:, env_file:-driven values you reference, and
build.args) and lists them in the resource's Environment Variables
tab.
Fill in the same values you'd put in .env.production: POSTGRES_USER,
POSTGRES_PASSWORD, POSTGRES_DB, WEB_DOMAIN, ADMIN_DOMAIN,
API_DOMAIN, THEME, SOCIAL_AUTH_ENABLED, NEXT_PUBLIC_DEMO_LOGINS,
the R2_* credentials, and any app secrets.
Coolify injects these both as build args (for admin/web's
NEXT_PUBLIC_* values) and as runtime environment — matching how the
Compose file already declares them.
Click Deploy. Watch the build/deploy log stream in the UI.
Confirm migrate runs and exits cleanly before api, admin, and
web start (same ordering as Dokploy — real Compose semantics).
Give Coolify's Traefik a short moment to issue Let's Encrypt certs for
the three domains, then visit each in a browser and confirm no CORS
errors and that the seeded demo login works.
Under the resource's Webhooks/Source settings, confirm auto-deploy
on push is enabled if you want GitHub pushes to redeploy automatically.
Render does not run docker-compose.yml files. Like Railway, it needs
an explicit config — Render's version is a render.yamlBlueprint
committed to the repo, which becomes the single source of truth for every
service, database, and env var. This part is closer to the Railway guide
than to Dokploy/Coolify.
Notes on the choices above, matching what's in docker-compose.prod.yml:
dockerContext mirrors the Compose build.context for each service:
apps/api for api (matches context: ./apps/api), and . (repo root)
for admin/web (matches context: ., needed for the pnpm workspace).
dockerfilePath is always relative to the repo root regardless of
dockerContext.
preDeployCommand replaces the standalone migrate service the same
way Railway's Pre-Deploy Command did — it runs in a fresh instance,
before the new api version goes live, and aborts the deploy on
non-zero exit. Wrap it in sh -c "..." so && is interpreted by a
shell, same reasoning as the Railway guide.
type: keyvalue is Render's current Redis-compatible managed store
(runs Valkey; redis is a deprecated alias for the same type). Setting
ipAllowList: [] keeps it unreachable from the public internet — only
other services in your Render workspace can reach it.
Build args: Render automatically forwards a Docker service's
envVars into the build as ARGs (as long as the Dockerfile declares
matching ARG lines) — so no separate build-args block is needed for
admin/web's NEXT_PUBLIC_* values, same requirement as in the
Railway guide.
sync: false on the R2 credentials means Render will prompt you to
type the actual values into the dashboard the first time the Blueprint
syncs, rather than committing secrets into render.yaml.
Connect your GitHub account if you haven't, then select the sentex
repository.
Render detects render.yaml at the repo root automatically. Give the
Blueprint instance a name and pick the branch to track.
Render shows a preview of every resource it's about to create
(sentex-postgres, sentex-redis, sentex-api, sentex-admin,
sentex-web). Review it, then fill in the prompted values for the
sync: false variables (your real R2 credentials).
Because domains: is already set per service in render.yaml, Render
provisions those custom domains automatically as part of the sync — you
don't need a separate manual step to attach them.
Render shows you the CNAME target for each domain (under that service's
Settings → Custom Domains). Create the corresponding CNAME records
at your DNS provider:
api.sentex.gritcms.com → CNAME → <target shown by Render>admin.sentex.gritcms.com → CNAME → <target shown by Render>sentex.gritcms.com → CNAME → <target shown by Render>
Wait for DNS to propagate and for Render to show each domain as
verified with an active TLS certificate.
Check each service's Logs tab — confirm the sentex-api pre-deploy
log shows ./migrate && ./seed exiting 0 before the service starts.
Visit all three domains, confirm no CORS errors, and confirm the seeded
demo login works.
Because Blueprints auto-redeploy affected services whenever
render.yaml changes, and each service still auto-deploys on pushes to
its watched paths, day-to-day pushes to main behave like the Railway
and Dokploy/Coolify GitHub-connected flows — no manual redeploy step
needed.
Fly.io is the most different of the four: there's no single "project" that
holds multiple services the way Railway/Render/Dokploy/Coolify have one.
Every deployable thing is its own Fly app with its own fly.toml, and
you orchestrate the monorepo yourself with flyctl flags rather than a
platform-level "root directory" setting. Databases are provisioned
separately too (Fly Postgres runs as your own VMs; Redis comes from Fly's
built-in Upstash integration).
Fly doesn't read a monorepo config format — each app gets its own TOML file
and you tell flyctl which Dockerfile and build context to use for it via
flags. Create these at the repo root (keeping them there, rather than
inside apps/*, keeps the build context flexible — see 4.4):
fly.web.toml — same shape as fly.admin.toml, with:
app = "sentex-web"
and its build args matching the web service's args from the Compose file
(no NEXT_PUBLIC_WEB_URL — web doesn't need its own URL as a build arg,
same as in the Compose file):
release_command on sentex-api is Fly's equivalent of Railway's
Pre-Deploy Command and Render's preDeployCommand — it spins up a
temporary Machine using the freshly built image, runs
./migrate && ./seed, and only proceeds to deploy the real release if
it exits 0. Same sh -c wrapping reasoning as the other platforms.
[build.args] is how Fly forwards Docker build arguments — this maps
directly to the build.args block for admin/web in
docker-compose.prod.yml. Build args aren't available at runtime, so
this only covers the NEXT_PUBLIC_*/THEME/SOCIAL_AUTH_ENABLED
values that Next.js needs baked into the bundle — exactly like the
Compose file's own comments describe.
Runtime secrets (Postgres/Redis URLs, R2 credentials, APP_URL,
CORS_ORIGINS) are not put in fly.toml — they go in via
fly secrets set in Part 4.5, the same way Fly handles all sensitive
runtime config.
# Postgres — runs as Fly Machines you own, not a separate managed productfly postgres create --name sentex-postgres --region jnb# Attach it to the api app — this auto-creates a DATABASE_URL secret on sentex-apifly postgres attach sentex-postgres --app sentex-api# Redis — Fly's built-in Upstash-managed integrationfly redis create# When prompted: name it sentex-redis, pick the same region (jnb), and# choose whether to enable eviction based on your caching needs.
fly postgres attach sets a DATABASE_URL secret directly on sentex-api
automatically. Since your app code expects discrete
POSTGRES_HOST/POSTGRES_PORT/POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB
variables rather than a single DSN, parse DATABASE_URL into those five
values in application startup code, or set the five secrets explicitly
from the credentials Fly prints when it creates the cluster (Part 4.5
shows the explicit-secrets approach, which needs no code change).
fly redis create prints a redis:// connection string — copy it for
Part 4.5.
Because admin and web need the repo root as build context (same reason
as every other platform in this doc — pnpm workspace access) while api's
context is just apps/api, deploy each with an explicit working directory
and --dockerfile/--config pair:
# api — context is apps/api, matching build.context: ./apps/api in Composefly deploy apps/api --config fly.api.toml --dockerfile apps/api/Dockerfile# admin — context is the repo root, matching build.context: . in Composefly deploy . --config fly.admin.toml --dockerfile apps/admin/Dockerfile# web — same reasoning as adminfly deploy . --config fly.web.toml --dockerfile apps/web/Dockerfile
The first argument to fly deploy is the build context sent to Docker; the
--config flag tells flyctl which fly.toml (and therefore which app)
you mean, and --dockerfile overrides the default <context>/Dockerfile
lookup.
The first fly deploy apps/api ... call will prompt to create the
sentex-api app if it doesn't exist yet (since fly.toml names it but you
haven't run fly launch interactively) — accept the prompt, or run
fly apps create sentex-api (and the equivalent for admin/web) ahead
of time if you'd rather do it explicitly.
Setting a secret triggers a new deploy of sentex-api automatically (which
also reruns release_command, safe since it's idempotent). admin and
web don't need runtime secrets in this stack — their configuration is
entirely build-time ([build.args] in step 4.2).
fly certs add api.sentex.gritcms.com -a sentex-apifly certs add admin.sentex.gritcms.com -a sentex-adminfly certs add sentex.gritcms.com -a sentex-web
Each command prints the DNS record(s) to create — typically an A/AAAA
pair pointing at Fly's anycast IPs, or a CNAME depending on whether the
hostname is a root domain or subdomain. Create those records at your DNS
provider, then poll status until issued:
fly certs check api.sentex.gritcms.com -a sentex-api
Fly has no native "connect a GitHub repo and auto-deploy on push" toggle
the way the other three platforms do — the standard pattern is a GitHub
Actions workflow that calls flyctl deploy for each app:
Generate a deploy token: fly tokens create deploy -a sentex-api (and
the same for sentex-admin, sentex-web, or one org-wide token if you
prefer — see fly tokens create org).
Optionally add paths: filters per job (mirroring the Watch Paths
concept from the Railway guide) so a change under apps/web/** doesn't
trigger an unnecessary sentex-api rebuild.