Skip to content
ChatfuelSDK
Deployment

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-app

Which variable goes where

Variable--build-argRuntime env
VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEYyesyes — the pair is what the server reads to decide the gate
VITE_CHATFUEL_WORKSPACE_IDyesyes — the admin panel reads it as the workspace whose last bot may not be deleted
VITE_APP_NAME, VITE_APP_LOGOyesthey name and mark the app inside the bundle
VITE_BASE_PATHyes, and it is declared outside the ENV blockno — BASE_PATH is its runtime half
CHATFUEL_TOKEN, SUPABASE_SERVICE_ROLE_KEY, PUBLISHING_SECRET, ADMIN_PASSWORD, ADMIN_COOKIE_SALTneveryes
Everything else the server reads — CHATFUEL_WORKSPACE_ID, HOST, PORT, PUBLIC_URL, ALLOWED_ORIGINS, the ceilings and the fencesneveryes

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.

On this page