End-to-end runbook for deploying dev-pulse to Coolify, alongside (not replacing) the Fly.io deploy. See COOLIFY-SCOPE.md for the design decisions behind this — read that first if you're wondering why something is shaped the way it is.
All commands run from /home/user/code/rust/dev-pulse unless noted.
One Coolify application container runs two processes under tini(1):
| # | Process | Port | Role |
|---|---|---|---|
| 1 | Caddy 2 | 0.0.0.0:8080 | Static SPA + reverse proxy. |
| 2 | dev-pulse | 127.0.0.1:8731 | Rust backend (axum + dp-server). Owns the sqlite auth sidecar at /data/auth.db, OAuth + webhook surfaces, and the scheduler. |
Postgres is not bundled in the image. It's a separate Coolify
Postgres resource on the same server — first-class UI, S3 backups,
no pg_ctl fighting the entrypoint. This is the one structural
difference from the Fly deploy (which bundles PG on the volume
because Fly Managed Postgres isn't available in cdg). See
COOLIFY-SCOPE.md §2.3.
Coolify's Traefik terminates TLS at the edge (auto Let's Encrypt)
and forwards plain HTTP to the container on :8080 — the same role
fly-proxy plays for the Fly deploy. auto_https off in the
Caddyfile stays correct unchanged.
https://<your-domain>/
│
┌─────────▼──────────────┐
│ Traefik (TLS, Coolify) │
└─────────┬──────────────┘
│ plain HTTP, internal :8080
┌─────────▼──────────────┐
│ Caddy │
│ - serves the SPA │
│ - reverse_proxy → │
│ 127.0.0.1:8731 │
└────────┬───────────────┘
│
┌──────────▼──────────────┐
│ dev-pulse backend │
│ 127.0.0.1:8731 │
└─┬───────────────────┬───┘
│ │
┌────────────▼─────────┐ ┌──────▼──────────────┐
│ Coolify Postgres │ │ /data/auth.db │
│ resource (managed, │ │ (sqlite — sessions, │
│ separate container) │ │ oauth identities) │
└───────────────────────┘ └──────────┬───────────┘
│
Coolify persistent volume
starter's dependency surface must be fully pushed toorigin/master. Unlike Fly (which builds from your local working directory, uncommitted changes included), thestartersubmodule only sees what's actually on GitHub. Ifdev-pulse'smaindepends onstartercode that only exists locally and uncommitted, the Coolify build fails on a missing symbol even thoughfly deployworks fine. Concretely, as of this writingdev-pulse'scrates/dp-rest/src/me_password.rscallsstarter_auth_users::admin::change_password, which exists only in the localrust/starterworking tree's uncommitted diff — the Coolify build will fail until that lands onstarter'smaster. Runmake coolify-local-buildafter anystarterchange thatdev-pulsenewly depends on, to catch this before it hits Coolify.- A Coolify server, reachable and provisioned. Rust release builds take ~10–15 min and several GB of RAM, and Coolify builds on the target server itself (no remote builder farm) — make sure the box isn't a small shared VPS, or the build will peg it alongside the running app. If it's undersized, build the image in CI instead and point Coolify at a pre-built image (Coolify's "Docker Image" build pack) — see COOLIFY-SCOPE.md §2.1 option (c).
git submodule update --initin this repo, so./starteris populated locally (only needed for local image testing — Coolify's own clone checks out submodules on its own).- A second GitHub OAuth App if you're running this alongside Fly — the two deploys can't share one callback URL (see GitHub OAuth App config below).
make coolify-local
# ...
make coolify-local-logs
make coolify-local-down # keeps volumesThis builds Dockerfile.coolify with build context = this repo (not
the parent dir — see COOLIFY-SCOPE.md §2.1) and runs it against a
local Postgres container standing in for the Coolify Postgres
resource. Catches build/entrypoint regressions before burning a
Coolify build cycle.
In the Coolify UI:
- New Resource → Application → your Git source → this repo.
- Build Pack: Dockerfile.
- Base Directory:
/(this repo is the deploy unit — no parent-directory trick needed, unlike Fly.starterresolves via the git submodule Coolify checks out automatically). - Dockerfile Location:
Dockerfile.coolify. - Ports Exposes:
8080. - Health Check Path:
/health. - Domain:
https://<your-domain>— Traefik issues the cert. - Persistent Storage: volume →
/data(holds onlyauth.db, small — no Postgres data lives here, unlike Fly).
New Resource → Database → PostgreSQL (version 16, to match). Once
it's up, copy its internal connection string — Coolify shows this
in the resource's "Internal" connection tab (something like
postgres://<user>:<pass>@<resource-name>:5432/<db>, reachable from
other containers on the same Coolify network, not from the internet).
In the application's Environment Variables tab:
| Variable | Value | Notes |
|---|---|---|
DATABASE_URL |
the Postgres resource's internal URL from Step 2 | Runtime + no default — the entrypoint fails fast if unset (COOLIFY-SCOPE.md §2.3 drops the Fly-style auto-build-from-POSTGRES_PASSWORD fallback, since there's no bundled PG to build a URL for). |
DP_PUBLIC_BASE_URL |
https://<your-domain> |
Drives the OAuth redirect_uri and every absolute URL the backend emits. |
DP_BIND_ADDR |
127.0.0.1:8731 |
Unchanged from Fly. |
DP_DEFAULT_RETURN |
/ |
Unchanged from Fly. |
DP_AUTH_SQLITE_URL |
sqlite:/data/auth.db?mode=rwc |
Unchanged from Fly. |
DP_SCHEDULER_ENABLE |
false initially |
Flip once seeded — see Step 6. |
DP_GITHUB_OAUTH_CLIENT_ID |
GitHub OAuth App client ID | Not secret, but env-driven. |
RUST_LOG |
info,dev_pulse=info,dp_server=info,sqlx=warn |
Unchanged from Fly. |
Mark these as runtime secrets (Coolify's secret toggle on the env var):
| Secret | Source | Notes |
|---|---|---|
GITHUB_PAT |
classic / fine-grained PAT | Same scopes as Fly: repo:status, public_repo, read:org, read:user. |
GITHUB_WEBHOOK_SECRET |
openssl rand -hex 32 |
Paste the same value into the GitHub App webhook settings. |
OAUTH_GITHUB_CLIENT_SECRET |
GitHub OAuth App | Client secret. |
DP_GITHUB_ALLOW_ORGS |
operator, e.g. ["NubeIO","NubeDev","PJNube"] |
JSON array, pasted into a Coolify UI field — no shell in the path, so the FLY.md pitfall #7 quoting trap doesn't apply here. It's still envsubst'd raw into TOML, so a malformed value still crash-loops the container — double-check it's valid JSON before saving. |
POSTGRES_PASSWORD drops out entirely — there's no bundled PG
role for it to configure.
- Homepage URL —
https://<your-domain> - Authorization callback URL —
https://<your-domain>/auth/oauth/github/callback
If Fly is still live, this must be a separate OAuth App from the one Fly uses — they can't share a callback URL.
Push to the branch Coolify is watching (or hit Deploy in the UI —
Coolify builds Dockerfile.coolify on the server, no CLI step here).
Migrations run inside the entrypoint before Caddy and dev-pulse
start, same placement as Fly — Coolify has no release_command
either.
First build: ~10–15 min (Rust release + pnpm SPA), same as Fly. No Postgres apt-install step to slow it down further, which is the one place this is faster than Fly's image.
Use Coolify's browser terminal, or docker exec on the host if you
have shell access to the server:
docker exec -it <container-id> sh -c \
"cd /app && /usr/local/bin/dev-pulse create-admin \
--config /etc/dev-pulse/config.toml \
--email dev@dev.com \
--password dev123456789"Unlike Fly (FLY.md pitfall #9), there's no bundled PG for a second
invocation to race with — coolify-entrypoint.sh's non-serve
branch execs dev-pulse <subcmd> directly, nothing else to start.
Verify:
curl -i -X POST https://<your-domain>/auth/login \
-H 'content-type: application/json' \
-d '{"email":"dev@dev.com","password":"dev123456789"}'
# Expect: HTTP/2 200 with `set-cookie: starter_session=…`Same commands as Fly (FLY.md Step 5), just shelled in differently:
docker exec -it <container-id> sh -c \
"cd /app && /usr/local/bin/dev-pulse import-my-orgs \
--config /etc/dev-pulse/config.toml"
docker exec -it <container-id> sh -c \
"cd /app && /usr/local/bin/dev-pulse import-my-repos \
--config /etc/dev-pulse/config.toml \
--orgs NubeDev,NubeIO,PJNube \
--active-within-days 365 \
--max 200"Trigger the first sync and enable the scheduler exactly as in
FLY.md → Trigger the first sync /
→ Enable the scheduler —
set DP_SCHEDULER_ENABLE=true in the Coolify Environment Variables
tab and redeploy (or restart, if Coolify applies env changes without
a rebuild — check the resource's env var docs).
curl -sI "https://<your-domain>/health"
curl -s "https://<your-domain>/openapi.json" | python3 -m json.tool | head
curl -sI "https://<your-domain>/" | head -3Check the Coolify UI's health-check indicator (gates Traefik routing — an unhealthy container gets no traffic even if it's up).
| File | Purpose |
|---|---|
| Dockerfile.coolify | Multi-stage: Rust release → pnpm SPA → debian-slim runtime with Caddy + tini + gettext-base + curl. No Postgres install. Build context is this repo; recreates the /src/starter + /src/dev-pulse sibling layout Cargo/pnpm expect by relocating the starter submodule inside the build stages. |
| .dockerignore | Scoped to this repo as build context (not the parent dir — that's ../.dockerignore, used by Fly/compose). |
| Caddyfile | Unchanged from Fly — Traefik plays fly-proxy's role identically. |
| scripts/coolify-entrypoint.sh | Fork of fly-entrypoint.sh with start_postgres/stop_postgres removed. Still does envsubst → /etc/dev-pulse/config.toml, still runs migrate before serve, still cd /app for the authz policy path, still Caddy-background + backend-foreground under tini. Fails fast if DATABASE_URL isn't set. |
| crates/dev-pulse/config.coolify.toml | Runtime config template, identical shape to config.fly.toml. |
| docker-compose.coolify-local.yml | Local smoke-test harness — app + a throwaway Postgres container standing in for the Coolify resource. |
| .gitmodules | Pins the starter submodule at ./starter (COOLIFY-SCOPE.md §2.1 option (a)). |
| Makefile | coolify-local, coolify-local-down, coolify-local-reset, coolify-local-logs, coolify-submodule-update. No coolify-deploy — Coolify deploys via git-push or its API, not a CLI wrapper. |
See the Step 3 table above for the full list. Everything not called out there as changed is identical to FLY.md → Environment variables.
/data/
└── auth.db # sqlite — starter-auth-users + starter-auth-oauth row families
Much smaller than Fly's volume — no pgdata/ here, Postgres data
lives in the Coolify Postgres resource's own volume, backed up
through Coolify's built-in S3 backup UI (configure that on the
Postgres resource, not the app).
- Reset sessions only (PG data untouched, it's a separate
resource):
docker exec -it <container-id> rm -f /data/auth.db # restart the app container from the Coolify UI
- Full reset — wipe the Postgres resource from its own Coolify UI
(separate from the app), and/or delete + recreate the
/datavolume from the app's Storage tab.
starter is vendored as a git submodule, not a sibling checkout —
bumping it is a deliberate step, not automatic:
make coolify-submodule-update # pulls latest starter, updates the pointer
git add starter
git commit -m "bump starter submodule"
git pushCoolify then rebuilds against the new pointer on the next deploy.
Worth stating explicitly — most of the risk surface, none of it moves. See FLY.md's Operational pitfalls for the war stories behind each of these:
- The Caddyfile — Traefik plays fly-proxy's exact role.
auto_https offstays correct. Thehandle-block ordering fix (FLY.md pitfall #4) stays correct. - Migrations in the entrypoint — Coolify has no
release_commandeither. - The authz policy relative-path workaround (FLY.md pitfall #3).
- The pnpm react/@types/react overrides (FLY.md pitfall #6).
- Rust MSRV pinning (FLY.md pitfall #2) —
Dockerfile.coolifyuses the samerust:1.90-slim-bookwormbase, bump in lockstep.
- No bundled Postgres (§2.3) — a Coolify resource instead. This
also means the
*)subcommand branch in the entrypoint no longer races a second PG instance (FLY.md pitfall #9 doesn't exist here). - Build context is this repo, not the parent directory — no
--ignorefileflag exists on Coolify, so.dockerignorein the repo root is authoritative, andstarteris a submodule rather than a sibling checkout. DP_GITHUB_ALLOW_ORGSis pasted into a UI field, not a shell arg — the quoting trap in FLY.md pitfall #7 is specific tofly secrets set's shell invocation and doesn't apply here (the JSON-validity requirement still does).
Symptoms and fixes largely mirror FLY.md → Troubleshooting. The Coolify-specific deltas:
Same root cause as FLY.md's 503 entry — malformed
DP_GITHUB_ALLOW_ORGS JSON. Check the container logs in the Coolify
UI (or docker logs <container-id>).
The entrypoint's fail-fast guard tripped — DATABASE_URL is unset or
empty in the Environment Variables tab. Copy it fresh from the
Postgres resource's Internal connection tab (Step 2); it can change
if the resource is recreated.
Check container logs for the same signatures as FLY.md's
Health check failing
section (envsubst: command not found, TOML parse errors). The
permission denied on /data/pgdata failure mode in that list is
Fly-specific and can't happen here — there's no /data/pgdata.
See Prerequisites #1 — the Coolify server builds on itself, not a remote builder. If this bites, move to a CI-built image (COOLIFY-SCOPE.md §2.1 option (c)) and switch the app's build pack from Dockerfile to Docker Image.
- COOLIFY-SCOPE.md — the design doc this runbook implements.
- FLY.md — the Fly.io deploy this one runs alongside.
- DOCKER.md — plain HTTP local compose stack (unrelated to either deploy target).