diff --git a/docker-compose.coolify.sqlite.yml b/docker-compose.coolify.sqlite.yml new file mode 100644 index 000000000..627ae420a --- /dev/null +++ b/docker-compose.coolify.sqlite.yml @@ -0,0 +1,64 @@ +# docker-compose.coolify.sqlite.yml +# Instatic on Coolify — single container, SQLite. +# +# The simplest Coolify deployment: one service, two volumes, no database server. +# Pick this for a single-site install with one or two admins. For multiple +# simultaneous editors or scheduled database backups, use the Postgres stack in +# docker-compose.coolify.yml instead. Full trade-offs and the setup walkthrough: +# docs/deployment/coolify.md. +# +# Coolify setup: +# Resource: Docker Compose (Git repository, or Empty + paste this file) +# Compose file: docker-compose.coolify.sqlite.yml (extension must match exactly) +# Base directory: / +# Domain: set it on the `instatic` service. Coolify issues the TLS +# certificate and routes :443 to container port 3001. +# +# The header comments in docker-compose.coolify.yml explain why this file has no +# `networks:`, `ports:`, `container_name:`, or `restart:` keys. + +services: + instatic: + image: ${INSTATIC_IMAGE:-ghcr.io/corebunch/instatic:latest} + environment: + # Declares the public route. Coolify assigns this service a domain and + # points Traefik at container port 3001. + - SERVICE_URL_INSTATIC_3001 + - PORT=3001 + - DATABASE_URL=sqlite:/app/data/cms.db + - UPLOADS_DIR=/app/uploads + - STATIC_DIR=/app/dist + # See docker-compose.coolify.yml for why this is mandatory: without it the + # session cookie loses Secure, the CSRF check compares the wrong origin, + # real-time co-editing is rejected, and MCP connectors read local-only. + - PUBLIC_ORIGIN=${SERVICE_URL_INSTATIC} + # Required — the image runs NODE_ENV=production, where boot fails if this + # is unset. Coolify generates it once and keeps it stable across + # redeploys; changing it strands every already-encrypted secret. + - INSTATIC_SECRET_KEY=${SERVICE_REALBASE64_32_INSTATIC} + # Audit-log and rate-limit client-IP attribution only, not CSRF. + - TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-172.16.0.0/12} + volumes: + # Two volumes rather than one shared root, and specifically at these two + # paths: the Dockerfile creates /app/uploads and /app/data and chowns them + # to `bun` before dropping to USER bun. A volume mounted at a path the + # image does NOT contain (e.g. the /app/storage layout the Railway and + # Render templates use) is created root-owned, and every write from the + # non-root container fails with EACCES. Railway works around that with + # RAILWAY_RUN_UID=0; Docker named volumes need no such workaround as long + # as they mount where the image already prepared the directory. + - instatic-uploads:/app/uploads + - instatic-data:/app/data + healthcheck: + # The oven/bun base image ships neither curl nor wget — see + # server/healthcheck.ts. Shorter start_period than the Postgres stack: + # there is no database server to wait on, only migrations. + test: ['CMD', 'bun', 'run', 'server/healthcheck.ts'] + interval: 30s + timeout: 5s + start_period: 30s + retries: 5 + +volumes: + instatic-uploads: + instatic-data: diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml new file mode 100644 index 000000000..154e0791b --- /dev/null +++ b/docker-compose.coolify.yml @@ -0,0 +1,102 @@ +# docker-compose.coolify.yml +# Instatic on Coolify — bundled Postgres. +# +# Coolify treats one Compose file as the single source of truth: there are no +# `-f a.yml -f b.yml` overlays, so this file is standalone rather than a layer on +# top of compose.prod.yml. For the single-container SQLite stack, use +# docker-compose.coolify.sqlite.yml instead. Trade-offs between the two, plus the +# full setup walkthrough: docs/deployment/coolify.md. +# +# Coolify setup: +# Resource: Docker Compose (Git repository, or Empty + paste this file) +# Compose file: docker-compose.coolify.yml (the extension must match exactly) +# Base directory: / +# Domain: set it on the `instatic` service. Coolify issues the TLS +# certificate and routes :443 to container port 3001. +# +# Conventions this file deliberately follows: +# - No `networks:`. Coolify creates an isolated bridge network per stack. +# Declaring one puts containers on two networks at once and makes Traefik +# route non-deterministically — Coolify documents this as a cause of +# intermittent HTTPS outages. +# - No `ports:`. Publishing a host port bypasses the proxy and would expose +# both the app and Postgres directly on the VPS. Routing comes solely from +# the SERVICE_URL_INSTATIC_3001 magic variable below. +# - No `container_name:`, `restart:`, or top-level `name:`. Coolify owns all +# three and renames services with a UUID suffix. + +services: + instatic: + # Service name matters: it becomes the auto-generated subdomain, and the + # SERVICE_URL_INSTATIC_* variables below are keyed to it. + image: ${INSTATIC_IMAGE:-ghcr.io/corebunch/instatic:latest} + environment: + # Declares the public route. Coolify assigns this service a domain and + # points Traefik at container port 3001. + - SERVICE_URL_INSTATIC_3001 + - PORT=3001 + - DATABASE_URL=postgres://${POSTGRES_USER:-instatic}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-instatic} + - UPLOADS_DIR=/app/uploads + - STATIC_DIR=/app/dist + # The one setting that must not be omitted. Coolify's Traefik terminates + # TLS and forwards plain HTTP, and server/auth/security.ts deliberately + # never trusts X-Forwarded-Proto/Host. Without this the server believes it + # is http:// and four things break or silently degrade: the session cookie + # loses its Secure flag, the CSRF origin check compares the wrong origin, + # the real-time co-editing WebSocket is rejected by the same origin guard, + # and MCP connector URLs resolve local-only instead of public-https. + # ${SERVICE_URL_INSTATIC} resolves to the domain assigned above, scheme + # included, and follows a custom domain set later in the UI. + - PUBLIC_ORIGIN=${SERVICE_URL_INSTATIC} + # Base64 32-byte AES key for reversible server secrets (AI provider + # credentials, plugin secret settings, TOTP seeds). The image runs with + # NODE_ENV=production, where boot FAILS outright if this is unset. + # REALBASE64_32 produces base64 of 32 random bytes — exactly the shape + # server/secrets/masterKey.ts validates. Coolify generates it once and + # keeps it stable across redeploys; changing it strands every value + # already encrypted under the old key. + - INSTATIC_SECRET_KEY=${SERVICE_REALBASE64_32_INSTATIC} + # Client-IP attribution for audit logs and rate-limit keys only — NOT + # CSRF, which is driven entirely by PUBLIC_ORIGIN. Trusting the Docker + # bridge range is safe here precisely because no host port is published, + # so only Coolify's proxy can reach this container. + - TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-172.16.0.0/12} + volumes: + - instatic-uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + healthcheck: + # The oven/bun base image ships neither curl nor wget, so the probe is a + # Bun script that fetches GET /health — see server/healthcheck.ts. + # + # start_period is longer than compose.prod.yml's 20s because Coolify gates + # both the deploy and Traefik routing on this check, and the first boot + # runs every migration against an empty database before /health answers. + test: ['CMD', 'bun', 'run', 'server/healthcheck.ts'] + interval: 30s + timeout: 5s + start_period: 60s + retries: 5 + + postgres: + # Pinned to the same major as compose.prod.yml so the two stacks stay + # data-compatible — a dump from one restores into the other. + image: postgres:16 + environment: + - POSTGRES_DB=${POSTGRES_DB:-instatic} + - POSTGRES_USER=${POSTGRES_USER:-instatic} + # Coolify generates this once and persists it. It is symbol-free, so it is + # safe to embed unescaped in the DATABASE_URL above. + - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES} + volumes: + - instatic-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + instatic-uploads: + instatic-postgres-data: diff --git a/docs/deployment/README.md b/docs/deployment/README.md index d343eeb04..dc80c6ab7 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -15,6 +15,7 @@ Instatic is one Bun server packaged by the root `Dockerfile`. The server reads r | Render SQLite template | Managed Docker install outside Railway | SQLite file | One Render disk mounted at `/app/storage` | [render.md](render.md) | | Render Postgres template | Managed Postgres install outside Railway | Render Postgres | Render disk for uploads, Render Postgres storage for DB | [render.md](render.md) | | VPS Docker Compose | Self-hosted server, full control | SQLite or bundled Postgres | Docker named volumes | [vps.md](vps.md) | +| Coolify | Self-hosted PaaS with managed TLS and deploys | SQLite or bundled Postgres | Docker named volumes | [coolify.md](coolify.md) | | Generic Docker host | Any platform that runs the Dockerfile/image | SQLite or external Postgres | A mounted directory/volume for DB/uploads | [docker-image.md](docker-image.md) | | VPS HTTPS | Public domain on a VPS | Unchanged | Caddy cert volume plus app volumes | [tls-caddy.md](tls-caddy.md) | @@ -100,6 +101,7 @@ SQLite installs also need the SQLite database file on persistent storage. On pla | [railway.md](railway.md) | Railway templates for SQLite and Postgres | | [render.md](render.md) | Render Blueprint templates for SQLite and Postgres | | [vps.md](vps.md) | Docker Compose on a VPS, both SQLite and Postgres | +| [coolify.md](coolify.md) | Coolify Compose templates for SQLite and Postgres | | [docker-image.md](docker-image.md) | Generic Docker image contract and `docker run` examples | | [tls-caddy.md](tls-caddy.md) | Caddy TLS overlay for VPS Compose installs | | [backup-restore.md](backup-restore.md) | Database and uploads backup/restore | @@ -112,4 +114,5 @@ SQLite installs also need the SQLite database file on persistent storage. On pla - `server/index.ts` — migrations, media storage, and server boot - `Dockerfile` — production image contract - `compose.prod.yml`, `compose.sqlite.yml`, `compose.tls.yml`, `compose.build.yml` — VPS Compose files +- `docker-compose.coolify.yml`, `docker-compose.coolify.sqlite.yml` — Coolify Compose templates - `docs/deployment/render/sqlite/render.yaml`, `docs/deployment/render/postgres/render.yaml` — Render Blueprint templates diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md new file mode 100644 index 000000000..d58cc7b55 --- /dev/null +++ b/docs/deployment/coolify.md @@ -0,0 +1,164 @@ +# Coolify Deployment + +This guide covers running Instatic on a [Coolify](https://coolify.io) instance using the Compose files at the repository root. + +Coolify is a self-hosted PaaS: it manages Docker on your own VPS, runs a Traefik reverse proxy in front of every resource, issues Let's Encrypt certificates, and redeploys on a git push or an image update. Compared with [vps.md](vps.md), you give up hand-running `docker compose` and gain automatic TLS, scheduled backups, and a deploy UI. + +--- + +## TL;DR + +| Template | File | Database | Volumes | +|---|---|---|---| +| Postgres | `docker-compose.coolify.yml` | Bundled `postgres:16` service | `instatic-uploads`, `instatic-postgres-data` | +| SQLite | `docker-compose.coolify.sqlite.yml` | SQLite file in a volume | `instatic-uploads`, `instatic-data` | + +Both pull `ghcr.io/corebunch/instatic:latest`, expose the app on container port `3001`, and let Coolify generate every secret. There is nothing to fill in by hand before the first deploy — assign a domain and press Deploy. + +## Why Coolify Needs Its Own Compose Files + +Coolify treats **one** Compose file as the single source of truth. It does not support the `-f base.yml -f override.yml` layering that `compose.prod.yml` + `compose.sqlite.yml` + `compose.tls.yml` rely on, and three of the conventions in those files are actively wrong here: + +| VPS Compose | Coolify | +|---|---| +| `ports: "${HOST_PORT}:3001"` publishes on the host | No `ports:` — Traefik routes to the container; publishing a port bypasses the proxy and exposes Postgres | +| `compose.tls.yml` adds a Caddy container for TLS | Coolify's own Traefik terminates TLS and renews certificates | +| `PUBLIC_ORIGIN` derived from a `DOMAIN` env var you set | `PUBLIC_ORIGIN` derived from Coolify's `SERVICE_URL_INSTATIC` magic variable | + +The Coolify files also declare no `networks:` block. Coolify creates an isolated bridge network per stack; adding one puts containers on two networks at once and makes Traefik route non-deterministically, which Coolify documents as a cause of intermittent HTTPS outages. + +## Which Database? + +The database is chosen only by `DATABASE_URL`, and each template hardcodes one. Neither is a default you should feel obliged to change — pick by how many people edit the site at once. + +### SQLite — `docker-compose.coolify.sqlite.yml` + +Use for a single-site install with one or two admins. This is the simplest thing that works. + +**Pros** + +- One container. Less RAM, faster cold start, one less thing to fail. +- The whole install is two volumes. No database server to configure, tune, or major-version upgrade. +- No credentials to rotate. + +**Cons** + +- SQLite serializes writes. Real-time co-editing works, but several people saving heavily at once will contend. +- You cannot scale past a single app container — the database file cannot be shared. +- Backups need care. A file copy taken while the server is writing can be torn, so a scheduled dump should use `sqlite3 .backup` rather than `cp`. + +### Postgres — `docker-compose.coolify.yml` + +Use when more than a couple of people edit simultaneously, or when you want a database you can dump without quiescing the site. + +**Pros** + +- Concurrent writers, which matters for a team using real-time co-editing. +- Room to scale the app container later without moving the database first. +- `pg_dump` takes a consistent snapshot of a live database — schedule it as a Coolify Scheduled Task against the `postgres` service. You can also inspect and replicate it with ordinary Postgres tooling. + +**Cons** + +- A second container: roughly 100–200 MB more RAM. +- A second volume to keep track of. +- Slower first deploy while Postgres initializes. + +### Switching Later + +Switching is an export/import, not a config change. Both engines run the same migrations and hold the same tables, but nothing copies rows between them automatically — use the CMS transfer export before switching and import afterwards. Plan the choice up front. + +## Setup + +1. **Create the resource.** In your project, add a resource of type **Docker Compose**. Point it at this repository, or choose the Empty variant and paste the file contents. +2. **Set the Compose file path.** `docker-compose.coolify.yml` or `docker-compose.coolify.sqlite.yml`. The extension must match exactly or Coolify will not load the file. Leave Base Directory as `/`. +3. **Assign the domain to the `instatic` service.** This is the step that matters most — see below. Coolify then issues the certificate and routes `:443` to container port `3001`. +4. **Deploy.** The first deploy pulls the image, runs every migration against the empty database, and reports healthy once `GET /health` answers. +5. **Open `https://your-domain/admin`** and complete the setup wizard. It creates the site, the first owner account, and a starter homepage. + +There is no `ADMIN_EMAIL` / `ADMIN_PASSWORD` seeding path — the first admin is always created interactively. Do not add a migration command or a one-shot migration service either: `server/index.ts` runs the migrations itself before the HTTP server starts. + +## Domains and `PUBLIC_ORIGIN` + +Coolify's Traefik terminates TLS and forwards plain HTTP to the container, and Instatic deliberately never trusts `X-Forwarded-Proto` or `X-Forwarded-Host` to reconstruct its own origin. It reads `PUBLIC_ORIGIN` instead. Both Compose files wire it up automatically: + +```yaml +- SERVICE_URL_INSTATIC_3001 # declares the route; Coolify assigns the domain +- PUBLIC_ORIGIN=${SERVICE_URL_INSTATIC} # reads that domain back, scheme included +``` + +Because the two are linked, setting a custom domain on the `instatic` service in the Coolify UI updates `PUBLIC_ORIGIN` with it. Nothing else to configure. + +Assign the domain to the **`instatic`** service specifically. The Postgres service must never get one. + +If `PUBLIC_ORIGIN` is wrong or missing, four things break — and only one of them is loud: + +| Symptom | Cause | +|---|---| +| Session cookie is sent without `Secure` | The server believes the request is plain HTTP. Silent — no error anywhere | +| Admin writes rejected with an origin error | CSRF check compares the browser's `Origin` against the wrong expected value | +| Real-time co-editing never connects | The collab WebSocket upgrade runs the same origin guard | +| MCP connectors show `local-only` | OAuth issuer URLs are built from the same origin and must be public HTTPS | + +To serve a second origin — say an apex domain alongside `www` — override `PUBLIC_ORIGIN` in the Coolify UI with a comma-separated list. + +## `INSTATIC_SECRET_KEY` + +The image runs with `NODE_ENV=production`, where the server **refuses to boot** without this key. Both files generate it with Coolify's `SERVICE_REALBASE64_32_INSTATIC` magic variable, which produces exactly the base64 32-byte AES key Instatic expects. + +It encrypts reversible server secrets: AI provider credentials, plugin secret settings, and MFA TOTP seeds. + +> **Do not edit or clear this value after the first deploy.** Coolify generates it once and keeps it stable across redeploys. Changing it strands every value already encrypted under the old key — AI credentials must be re-entered and MFA re-enrolled. + +Record it alongside your backups. Restoring a database without the matching key leaves those secrets unreadable. + +## Persistent Storage + +Both templates use **named volumes**, and Coolify appends the resource UUID to each so they never collide with another stack. + +| Volume | Mount | Contents | +|---|---|---| +| `instatic-uploads` | `/app/uploads` | Media originals and variants, fonts, plugin packages, published static artefacts under `published/current` | +| `instatic-postgres-data` | `/var/lib/postgresql/data` | Postgres data directory (Postgres template) | +| `instatic-data` | `/app/data` | SQLite database file (SQLite template) | + +> **Use named volumes, not bind mounts.** The image runs as the non-root `bun` user. A Coolify bind mount to a host path is created root-owned, and every write fails with `EACCES`. The mount paths above are created and chowned inside the `Dockerfile` before it drops privileges, so named volumes mounted there inherit the right ownership. This is also why neither template uses the single-volume `/app/storage` layout that the Railway and Render templates use — those platforms allow only one disk and work around the ownership problem with `RAILWAY_RUN_UID=0`. + +Back up the database *and* `instatic-uploads`; neither is recoverable from the other. See [backup-restore.md](backup-restore.md). + +## Proxy Behavior Worth Knowing + +- **WebSockets.** Real-time co-editing uses a WebSocket at `/admin/api/cms/site-socket`. Traefik forwards `Upgrade` / `Connection` headers by default, and the app keeps the connection alive with its own ping/pong frames, so no extra configuration is needed. If co-editing drops repeatedly, suspect `PUBLIC_ORIGIN` before the proxy — the upgrade runs the origin guard. +- **Long responses.** The server disables Bun's idle timeout because the AI endpoints stream NDJSON for as long as a model keeps working. Do not put a short response timeout in front of it. +- **Body sizes.** Media uploads accept up to 50 MiB and archive imports up to 256 MiB. Traefik imposes no limit of its own, but a CDN or WAF in front of Coolify might. +- **Caching.** Published assets are content-hashed and already ship `cache-control: public, max-age=31536000, immutable`, so a CDN can be layered on with no extra configuration. +- **`TRUSTED_PROXY_CIDRS`** defaults to the Docker bridge range `172.16.0.0/12`. It affects only client-IP attribution in audit logs and rate-limit keys — it plays no part in CSRF. Trusting that range is safe here because no host port is published, so nothing but Coolify's proxy can reach the container. Override it if your Coolify network uses a different range. + +## Updating + +The templates track `ghcr.io/corebunch/instatic:latest`. Redeploy in Coolify to pull the current image; migrations run automatically on the next boot. + +Pin a version for predictable upgrades by setting `INSTATIC_IMAGE` in the Coolify UI: + +```txt +INSTATIC_IMAGE=ghcr.io/corebunch/instatic:0.0.16 +``` + +## ARM64 Hosts + +The published image is built for `linux/amd64` only. On an ARM VPS, build from source instead: add a `build` block pointing at the repository `Dockerfile` and deploy with Coolify's Docker Compose build pack. + +```yaml +services: + instatic: + build: + context: . + dockerfile: Dockerfile + image: instatic:local +``` + +## Related + +- [README.md](README.md) — deployment index and the shared runtime contract +- [vps.md](vps.md) — hand-run Docker Compose on a plain VPS +- [docker-image.md](docker-image.md) — the image's environment-variable contract +- [backup-restore.md](backup-restore.md) — backing up the database and uploads diff --git a/scripts/build-release-bundle.ts b/scripts/build-release-bundle.ts index 75d616651..f106176fa 100644 --- a/scripts/build-release-bundle.ts +++ b/scripts/build-release-bundle.ts @@ -19,9 +19,12 @@ const bundleFiles = [ 'compose.prod.yml', 'compose.sqlite.yml', 'compose.tls.yml', + 'docker-compose.coolify.yml', + 'docker-compose.coolify.sqlite.yml', '.env.production.example', 'docs/deployment/README.md', 'docs/deployment/vps.md', + 'docs/deployment/coolify.md', 'docs/deployment/docker-image.md', 'docs/deployment/tls-caddy.md', 'docs/deployment/backup-restore.md', @@ -89,6 +92,17 @@ cp .env.production.example .env INSTATIC_IMAGE=ghcr.io/corebunch/instatic:${version} docker compose -f compose.prod.yml up -d \`\`\` +## Coolify install + +Create a Docker Compose resource pointing at one of: + +- \`docker-compose.coolify.yml\` (bundled Postgres) +- \`docker-compose.coolify.sqlite.yml\` (single container, SQLite) + +Assign a domain to the \`instatic\` service and deploy; Coolify generates every secret and terminates TLS. +Pin this release by setting \`INSTATIC_IMAGE=ghcr.io/corebunch/instatic:${version}\` in the Coolify UI. +Read \`docs/deployment/coolify.md\` first. + ## Railway image-source install Use \`ghcr.io/corebunch/instatic:${version}\` as the Railway service source. Attach a volume at \`/app/storage\` and set: diff --git a/src/__tests__/server/dockerConfig.test.ts b/src/__tests__/server/dockerConfig.test.ts index 0a6645aba..0cd693247 100644 --- a/src/__tests__/server/dockerConfig.test.ts +++ b/src/__tests__/server/dockerConfig.test.ts @@ -98,3 +98,113 @@ describe('self-host docker config', () => { expect(compose).toContain('TRUSTED_PROXY_CIDRS:') }) }) + +describe('coolify docker config', () => { + // Coolify reads the Compose file as prose-free YAML, but these files carry + // heavy explanatory comments — several of which quote the very keys asserted + // absent below ("No `networks:`", "No `ports:`"). Strip comment lines so the + // assertions describe the effective stack, not the documentation around it. + function effectiveYaml(path: string): string { + return readFileSync(path, 'utf8') + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')) + .join('\n') + } + + const COOLIFY_COMPOSE_FILES = [ + 'docker-compose.coolify.yml', + 'docker-compose.coolify.sqlite.yml', + ] + + it.each(COOLIFY_COMPOSE_FILES)('leaves proxy and lifecycle concerns to Coolify in %s', (path) => { + // Why this rule exists: + // Coolify owns the reverse proxy and the container lifecycle. Each of these + // keys silently takes something back from it: + // networks: puts containers on two networks at once, which makes + // Traefik route non-deterministically — Coolify documents + // this as a cause of intermittent HTTPS outages. + // ports: publishes on the host, bypassing the proxy entirely and + // exposing Postgres and the app directly on the VPS. + // container_name: collides with the UUID-suffixed name Coolify assigns. + // restart: Coolify sets its own restart policy. + // None of these fail loudly, so they need a gate rather than a review. + const compose = effectiveYaml(path) + + expect(compose).not.toContain('networks:') + expect(compose).not.toContain('ports:') + expect(compose).not.toContain('container_name:') + expect(compose).not.toContain('restart:') + }) + + it.each(COOLIFY_COMPOSE_FILES)('pulls the published image rather than building in %s', (path) => { + const compose = effectiveYaml(path) + + expect(compose).toContain('ghcr.io/corebunch/instatic:latest') + expect(compose).not.toContain('build:') + }) + + it.each(COOLIFY_COMPOSE_FILES)('wires the public origin to the Coolify-assigned domain in %s', (path) => { + // Why this rule exists: + // 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 PUBLIC_ORIGIN must be set explicitly or the + // server believes it is http:// and four things degrade — only one loudly: + // - the session cookie silently loses its Secure flag + // - the CSRF check compares against the wrong expected origin + // - the collab WebSocket upgrade is rejected by that same origin guard + // - MCP connector URLs resolve local-only instead of public-https + // SERVICE_URL_INSTATIC_3001 declares the route and assigns the domain; + // ${SERVICE_URL_INSTATIC} reads it back with the scheme included, so a + // custom domain set later in the Coolify UI carries through automatically. + const compose = effectiveYaml(path) + + expect(compose).toContain('SERVICE_URL_INSTATIC_3001') + expect(compose).toContain('PUBLIC_ORIGIN=${SERVICE_URL_INSTATIC}') + }) + + it.each(COOLIFY_COMPOSE_FILES)('generates a master key of the shape the server validates in %s', (path) => { + // The image runs NODE_ENV=production, where server/secrets/masterKey.ts + // throws MasterKeyConfigurationError at boot unless INSTATIC_SECRET_KEY + // decodes to exactly REQUIRED_KEY_BYTES (32). 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. + const compose = effectiveYaml(path) + + expect(compose).toContain('INSTATIC_SECRET_KEY=${SERVICE_REALBASE64_32_INSTATIC}') + }) + + it.each(COOLIFY_COMPOSE_FILES)('health-checks the app so Traefik only routes to a ready container in %s', (path) => { + const compose = effectiveYaml(path) + + expect(compose).toContain('healthcheck:') + expect(compose).toContain('server/healthcheck.ts') + }) + + it('bundles Postgres with a readiness gate and persistent volumes', () => { + const compose = effectiveYaml('docker-compose.coolify.yml') + + expect(compose).toContain('image: postgres:16') + expect(compose).toContain('condition: service_healthy') + expect(compose).toContain('instatic-uploads:') + expect(compose).toContain('instatic-postgres-data:') + }) + + it('mounts the SQLite stack where the image already prepared the directories', () => { + // Why this rule exists: + // The image runs as the non-root `bun` user. The Dockerfile creates + // /app/uploads and /app/data and chowns them to bun BEFORE `USER bun`, so a + // named volume mounted at either path inherits that ownership. 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 such escape hatch here. + const compose = effectiveYaml('docker-compose.coolify.sqlite.yml') + + expect(compose).toContain('DATABASE_URL=sqlite:/app/data/cms.db') + expect(compose).toContain('instatic-uploads:/app/uploads') + expect(compose).toContain('instatic-data:/app/data') + expect(compose).not.toContain('/app/storage') + expect(compose).not.toContain('postgres') + }) +})