Skip to content

feat(deployment): add Coolify Compose templates for Postgres and SQLite - #475

Open
flyingwebie wants to merge 1 commit into
CoreBunch:mainfrom
flyingwebie:feat/coolify-compose-stack
Open

feat(deployment): add Coolify Compose templates for Postgres and SQLite#475
flyingwebie wants to merge 1 commit into
CoreBunch:mainfrom
flyingwebie:feat/coolify-compose-stack

Conversation

@flyingwebie

Copy link
Copy Markdown

Adds Coolify as a documented deployment target, alongside the existing Railway, Render, VPS Compose, and generic Docker paths.

What changed

  • docker-compose.coolify.yml — bundled Postgres 16 stack, app gated on a Postgres readiness healthcheck.
  • docker-compose.coolify.sqlite.yml — single-container SQLite stack.
  • docs/deployment/coolify.md — setup walkthrough, database trade-offs, domains/PUBLIC_ORIGIN, secret-key handling, persistence, updating, ARM64 notes.
  • docs/deployment/README.md — Coolify row in the TL;DR table, docs inventory, and Related file list.
  • scripts/build-release-bundle.ts — ships both Compose files and coolify.md in the release bundle, with a Coolify section in the generated install notes.
  • src/__tests__/server/dockerConfig.test.ts — a coolify docker config suite gating the invariants below.

Why Coolify needs its own Compose files

Coolify reads a single Compose file as the source of truth — there is no -f a.yml -f b.yml overlay mechanism — so these are standalone rather than a layer on compose.prod.yml. Both files deliberately omit networks:, ports:, container_name:, and restart:, because Coolify owns the per-stack bridge network, the Traefik route, and the container lifecycle. Declaring any of them fails silently rather than loudly (a second network makes Traefik route non-deterministically; a published port bypasses the proxy and would expose Postgres on the VPS), so the test suite gates them instead of leaving it to review.

Two settings are load-bearing and easy to get wrong:

  • PUBLIC_ORIGIN=${SERVICE_URL_INSTATIC} — Coolify's Traefik terminates TLS and forwards plain HTTP, and server/auth/security.ts deliberately never trusts X-Forwarded-Proto/Host. resolvePublicOrigins() in server/config.ts auto-detects Render and Railway but has no Coolify branch, so without this the server believes it is http:// and four things degrade — only one of them loudly: the session cookie loses its Secure flag, the CSRF origin check compares the wrong origin, the collab WebSocket upgrade is rejected by that same guard, and MCP connector URLs resolve local-only.
  • INSTATIC_SECRET_KEY=${SERVICE_REALBASE64_32_INSTATIC} — the image runs NODE_ENV=production, where server/secrets/masterKey.ts fails boot unless the key decodes to exactly 32 bytes. Coolify's REALBASE64_32 emits base64 of 32 random bytes; the similarly named BASE64_32 emits a bare 32-character string that is not base64 and would fail validation.

The SQLite stack mounts /app/uploads and /app/data as two named volumes rather than one shared root, because the Dockerfile creates exactly those two paths and chowns them to bun before USER bun. A volume mounted at a path the image does not contain — notably the single /app/storage root the Railway and Render templates use — is created root-owned and every write fails with EACCES. Railway works around that with RAILWAY_RUN_UID=0; there is no equivalent escape hatch here.

Impact

Operators get a Coolify install that is deploy-and-assign-a-domain, with Coolify generating the Postgres password and master key once and keeping them stable across redeploys. No runtime, server, or schema code changes — this is deployment configuration, docs, and tests only.

Verification

bun run build   # tsc -b && vite build — clean
bun test        # 6747 pass, 0 fail
bun run lint    # clean

docker compose -f docker-compose.coolify.yml config
docker compose -f docker-compose.coolify.sqlite.yml config
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.build.yml config

Branch is rebased onto current main and contains a single commit.

🤖 Generated with Claude Code

Coolify could not run any of the existing Compose files. It treats one
file as the single source of truth, so the `-f compose.prod.yml -f
compose.sqlite.yml -f compose.tls.yml` layering has nowhere to go; it
runs its own Traefik, so the Caddy container and its `./Caddyfile` bind
mount are redundant; and `ports:` would publish Postgres on the host,
bypassing the proxy the platform exists to manage. Two standalone files
instead, one per database engine.

PUBLIC_ORIGIN is the reason these files are worth shipping rather than
leaving to each operator. `resolvePublicOrigins` auto-detects Render and
Railway and has no Coolify branch, and `server/auth/security.ts`
deliberately never trusts X-Forwarded-Proto. Coolify's Traefik terminates
TLS and forwards plain HTTP, so an unset PUBLIC_ORIGIN leaves the server
believing it is http:// and four things degrade — only one of them
loudly: the session cookie silently drops its Secure flag, the CSRF check
compares the wrong expected origin, real-time co-editing never connects
because the WebSocket upgrade runs that same origin guard, and MCP
connector URLs resolve local-only. Wiring `SERVICE_URL_INSTATIC_3001`
alongside `PUBLIC_ORIGIN=${SERVICE_URL_INSTATIC}` fixes all four and
follows a custom domain set later in the UI.

INSTATIC_SECRET_KEY is the other one that has to be right the first time.
The image runs NODE_ENV=production, where masterKey.ts throws at boot
unless the value decodes to exactly 32 bytes. Coolify's REALBASE64_32
emits base64 of 32 random bytes; the similarly-named BASE64_32 emits a
bare 32-character string that is not base64 at all and would fail
validation. Its own Plausible template uses REALBASE64_32 for an
identically-shaped key, so this is precedent rather than inference.

The SQLite template mounts /app/uploads and /app/data rather than the
single /app/storage root the Railway and Render templates use. That
layout exists because those platforms allow one disk, and it needs
RAILWAY_RUN_UID=0 to work at all: a volume mounted where the image has
no directory is created root-owned, and the non-root `bun` user cannot
write to it. The Dockerfile already creates and chowns both of these
paths before dropping privileges.

The gate in dockerConfig.test.ts covers the invariants that fail
silently rather than loudly — a stray `networks:` block (Coolify's
documented cause of intermittent HTTPS outages), a `ports:` mapping, a
missing PUBLIC_ORIGIN, the wrong base64 variable, or a drift back to
/app/storage. Comments carry the reasoning so the next reader knows why
the rule exists.

Verified against the published image rather than by inspection: both
stacks reach healthy, the Postgres stack applies 24 migrations into 38
tables, volumes come up bun-owned and writable, /health answers 200, and
no host port is published. Documented that the image is linux/amd64 only,
which is not incidental — pulling it on arm64 fails outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant