Skip to content

Add optional Docker / Docker Compose support - #372

Merged
ArnasDon merged 4 commits into
ArnasDon:mainfrom
opastorello:feat/docker-support
Jul 29, 2026
Merged

Add optional Docker / Docker Compose support#372
ArnasDon merged 4 commits into
ArnasDon:mainfrom
opastorello:feat/docker-support

Conversation

@opastorello

Copy link
Copy Markdown
Contributor

Summary

Add an optional, self-contained container setup (Dockerfile + Docker Compose) for forkers who self-host on their own infra. Purely additive — next start and the Hostinger path are unchanged.

What changed

  • Dockerfile — multi-stage (deps → build → runtime) using Next.js output: "standalone"; runs as a non-root user; only .next/standalone, .next/static, and public land in the final image.
  • docker-compose.yml — single app service. Supabase stays external (no DB container). NEXT_PUBLIC_* are forwarded as build args; server secrets come from .env.local via env_file at runtime. Includes a healthcheck and restart: unless-stopped.
  • .dockerignore — excludes .git, node_modules, .next, docs, supabase, all .env*, etc.
  • docs/docker.md — usage guide, including the build-time (NEXT_PUBLIC_*, inlined into the client bundle) vs runtime (secrets) distinction and the --env-file .env.local note.
  • next.config.tsoutput: "standalone". No-op for next start / Hostinger; only the Docker build consumes it.
  • README.md — one line pointing to docs/docker.md after the quick start.

Database migrations remain out of scope for the container (applied via the Supabase CLI, matching the existing external-Supabase model).

Test plan

  • npm run typecheck clean.
  • npm run build succeeds (runs inside the Docker build).
  • docker compose --env-file .env.local up --build -d builds and starts; container reports healthy; / → 307 /dashboard, /login → 200.
  • Verified end-to-end on a real deploy (Coolify + external self-hosted Supabase): app boots, auth and DB reachable.

Note: the 5 local test failures in currency/date-utils are timezone/ICU-locale artifacts of a non-UTC dev machine and are unrelated to this change (the only source edit is output: "standalone"); they pass in CI (Linux/UTC).

Related

Closes #371.

@opastorello
opastorello requested a review from ArnasDon as a code owner July 12, 2026 03:30
@ArnasDon

Copy link
Copy Markdown
Owner

Great, well-documented addition — the multi-stage standalone Dockerfile is textbook: running as non-root and keeping server secrets out of the image (build args limited to NEXT_PUBLIC_*) is exactly right. I built it locally and confirmed output: "standalone" emits server.js plus the static/public paths your final stage copies, so it runs.

A couple of things before merge:

  1. PORT override breaks the container. docs/docker.md suggests setting PORT in .env.local, but env_file: .env.local injects that into the container, so Next starts listening on that port inside the container — while the "${PORT:-3000}:3000" mapping and the healthcheck still target 3000. A separate var, e.g. "${HOST_PORT:-3000}:3000" (leaving PORT for the container only), fixes it cleanly.

  2. Node version parity. The Dockerfile uses node:22-alpine while CI and engines are on Node 20 — worth matching to node:20-alpine for build/prod parity.

  3. Dead doc link. docs/docker.md links to docs/automations-and-cron.md, which doesn't exist yet (the /api/automations/cron endpoint does).

None of these affect the default happy path. Thanks for making self-hosting first-class!

@ArnasDon ArnasDon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment added in the main thread.

@opastorello

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read — all three are pushed, one commit each.

1. PORT override. You were right, and it's a bit worse than the mapping being off: env_file injects PORT into the container, so the server itself moves off 3000 while the mapping and the healthcheck stay there. The published port is now HOST_PORT, and I also pinned PORT: 3000 under environment (it takes precedence over env_file) so a leftover PORT in someone's .env.local can't desync it again. If you'd rather keep compose minimal and just swap the variable, say so and I'll drop the pin.

Verified with both PORT=8080 and HOST_PORT=8080 set in .env.local — the case that used to come up unreachable:

$ docker compose port app 3000
0.0.0.0:8080
$ docker logs wacrm-app-1 | tail -1
- Network:  http://0.0.0.0:3000
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:8080/
307 http://localhost:8080/dashboard
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/login
200

Container reports healthy. The plain docker run example had the same trap, so it now passes -e PORT=3000 too.

2. Node parity. All three stages are on node:20-alpine. Rebuilt from scratch; node --version inside the image is v20.20.2.

3. Dead doc link. docs/automations-and-cron.md was never written, so instead of pointing at nothing I inlined what it was standing in for: both cron endpoints (/api/automations/cron and /api/flows/cron), the x-cron-secret header, and the 503 they return until AUTOMATION_CRON_SECRET is set.

Two things worth flagging:

  • .env.local.example:77-81 carries the same dangling reference to that file. I left it alone since it predates this PR and is outside its scope — happy to fix it here or separately, your call.
  • npm run format also rewrites the healthcheck array in docker-compose.yml to single quotes (prettier's singleQuote), so that shows up in the diff. Purely cosmetic.

npm run typecheck and npm run lint are clean. npm test still shows the same 5 failures in currency/date-utils on my machine, and I can now be specific that it's environment and not code: with TZ=UTC the two date-utils ones pass, and the currency ones are Intl.NumberFormat picking up my system locale's grouping (ZZZ 1.234 where the test wants 1,234).

One last thing — CI on this branch is sitting at action_required, so it needs your approval to actually run.

Give forkers who self-host on their own infra a first-class container
path instead of hand-rolling a Dockerfile and guessing the Next.js
standalone wiring.

- Multi-stage Dockerfile (deps -> build -> runtime) using Next.js
  output: "standalone"; runs as a non-root user.
- Single-service docker-compose.yml — Supabase stays external, so no
  database container is included.
- .dockerignore and docs/docker.md documenting the setup, including the
  build-time (NEXT_PUBLIC_*, inlined into the client bundle) vs runtime
  (server secrets via env_file) split.
- Set output: "standalone" in next.config.ts. This is a no-op for
  next start and the Hostinger path; only the Docker build consumes it.

Purely additive: no dependency or stack changes, existing deploy paths
are unaffected. Database migrations remain out of scope for the
container (applied via the Supabase CLI, matching the external-Supabase
model).
Setting PORT in .env.local moved the port the server listens on
*inside* the container (env_file injects it), while the mapping and
the healthcheck stayed on 3000 — so the container came up
unreachable.

The published port is now HOST_PORT, and PORT is pinned to 3000 via
`environment` (which takes precedence over env_file) so a leftover
PORT in .env.local can no longer desync the app from the mapping.
Documented both, including for the plain-docker path.
The image was on node:22-alpine while CI pins node-version: 20 and
package.json declares engines.node >=20.0.0. Align all three stages
so the container builds and runs on the same major the project is
tested against.
docs/automations-and-cron.md was never written. Replace the pointer
with the details it was standing in for: both cron endpoints, the
x-cron-secret header, and the 503 they return until
AUTOMATION_CRON_SECRET is set.
@opastorello
opastorello force-pushed the feat/docker-support branch from 7aec7de to fbfe07c Compare July 29, 2026 16:55
@opastorello

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (4887cad) — clean, no conflicts. output: "standalone" sits alongside the new SECURITY_HEADERS block without touching it, and the diff is still purely additive: 6 files, 186 insertions, 0 deletions.

Re-verified against today's main rather than the old base: image builds, container reports healthy, / → 307 /dashboard, /login → 200, and the new CSP / X-Frame-Options headers come through the standalone server correctly. Also confirmed mcp-server isn't an npm workspace and is already excluded by .dockerignore, so the root npm ci in the image is unaffected by it.

One thing I should have raised in my last comment rather than just doing what you asked:

Node 20 is past end-of-life. Security support ended 30 Apr 2026, and 20.20.2 — which is what node:20-alpine now resolves to, and what's in the image I built — is the final release of that line. So the parity we just established is parity with a runtime that stopped receiving patches three months ago.

I've kept the commit as node:20-alpine because that's what you asked for and it's the correct call in isolation: the image shouldn't be on a different major than CI and engines. But the pair being in sync doesn't help much if both are pinned to something unsupported, and a container image is the part of this repo most likely to sit untouched in someone's infra for a year.

The actual fix is one level up and out of this PR's scope — node-version in ci.yml, engines.node, and the Dockerfile moving to 22 or 24 together. Happy to open that as a separate PR if you want it, or to leave it entirely; just didn't want to hand you EOL-parity without saying so.

@ArnasDon
ArnasDon merged commit 5cbc7fa into ArnasDon:main Jul 29, 2026
1 check passed
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.

Add optional Docker / Docker Compose support

2 participants