Docker
The shipped Dockerfile, the build-arg split that puts a secret in the image if you get it wrong, and why a container is never loopback.
A build arg ends up in the image. That is the whole reason the split exists: VITE_* values are
compiled into the client bundle and have to be --build-args, and everything the server reads —
CHATFUEL_TOKEN, SUPABASE_SERVICE_ROLE_KEY, CHATFUEL_WORKSPACE_ID — is runtime-only and must
never be one.
Build and run
docker build -t chatfuel-app \
--build-arg VITE_CHATFUEL_WORKSPACE_ID=... \
--build-arg VITE_SUPABASE_URL=... \
--build-arg VITE_SUPABASE_ANON_KEY=... \
--build-arg VITE_APP_NAME=... \
--build-arg VITE_APP_LOGO=... .
docker run -p 3000:3000 --env-file .env chatfuel-appWhich variable goes where
| Variable | --build-arg | Runtime env |
|---|---|---|
VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY | yes | yes — the pair is what the server reads to decide the gate |
VITE_CHATFUEL_WORKSPACE_ID | yes | yes — the admin panel reads it as the workspace whose last bot may not be deleted |
VITE_APP_NAME, VITE_APP_LOGO | yes | they name and mark the app inside the bundle |
VITE_BASE_PATH | yes, and it is declared outside the ENV block | no — BASE_PATH is its runtime half |
CHATFUEL_TOKEN, SUPABASE_SERVICE_ROLE_KEY, PUBLISHING_SECRET, ADMIN_PASSWORD, ADMIN_COOKIE_SALT | never | yes |
Everything else the server reads — CHATFUEL_WORKSPACE_ID, HOST, PORT, PUBLIC_URL, ALLOWED_ORIGINS, the ceilings and the fences | never | yes |
The Supabase row is the one people get half right: passing the pair as build args alone produces a client that renders a sign-in screen and a server that knows nothing about it. Pass those values both ways, identical. Every name and what it does is on Environment variables.
A --build-arg is in the image. A secret passed that way stays in it whatever the container's
runtime environment says, and nothing at run time takes it back out.
VITE_BASE_PATH is declared as an ARG and deliberately left out of the ENV block above it. A
build arg reaches npm run build on its own, while ENV X=$X would define it as the empty string
on every build that passed none — and the client's config falls back to / only when the variable
is absent, which an empty string is not.
What the image is
Two stages on node:22-alpine. The builder declares the five VITE_* args, puts them in its
environment, installs from package.json and the lockfile with npm ci, copies the source and
runs npm run build. The runtime stage sets NODE_ENV=production and PORT=3000, installs with
npm ci --omit=dev, copies dist/ and server/dist/ across from the builder, drops to the node
user, exposes 3000 and runs node server/dist/entry.js.
ws, undici and https-proxy-agent are runtime dependencies rather than dev ones for exactly
that last step: under npm ci --omit=dev the production server still has to relay WebSockets and
honour HTTPS_PROXY.
The environment at run time
--env-file .env is one way in; variables set by your orchestrator are another. Nothing is baked:
.env and .env.* are in .dockerignore, so they never reach the build context and the container
environment is the only source — as are .envrc, *.pem, *.key, *.log and the .vercel/ and
.supabase/ working directories. The server does load a .env sitting next to it if one is there,
and real environment variables win over it, so mounting one is a choice you can make rather than
one the image makes for you.
PORT defaults to 3000, which is what EXPOSE, the runtime stage's own ENV and the
-p 3000:3000 above all assume.
REFUSING TO SERVE in a container
HOST defaults to 0.0.0.0, and a container has no other useful bind: -p 3000:3000 publishes a
port on the container's own interfaces, so a server bound to 127.0.0.1 inside it is reachable
from nothing. That removes one of the three answers to the open-mode refusal. With no auth gate,
an image built and run this way logs REFUSING TO SERVE and never binds, and the two ways on are:
- install the auth module, so the deployment has callers of its own —
npx @chatfuel/wizard --embed; - or set
CHATFUEL_OPEN_PROXY=1, which says the deployment is meant to answer strangers under one token because something in front of it authenticates. It is a signature, not a fix.
The second refusal, ALLOWED_ORIGINS set to *, takes neither: name the origins that serve your
app. Who may reach it is the reasoning.
Sub-paths work here
Unlike Vercel — see Serving from a sub-path. BASE_PATH goes in the
runtime environment like any other server variable, and VITE_BASE_PATH goes on the build line
and nowhere else: an image built without it is pinned to / however the container is later run.