Deploying with Docker
A small production image built from gio build standalone: only Node and one folder, running as an unprivileged user with a health check.
npm create giojs@latest my-app -- --docker # a new app
npx create-giojs add docker # an existing app
docker compose up --build # → http://localhost:3000pnpm create giojs my-app --docker # a new app
pnpm dlx create-giojs add docker # an existing app
docker compose up --build # → http://localhost:3000yarn create giojs my-app --docker # a new app
yarn dlx create-giojs add docker # an existing app
docker compose up --build # → http://localhost:3000bun create giojs my-app --docker # a new app
bunx create-giojs add docker # an existing app
docker compose up --build # → http://localhost:3000The Dockerfile
The build stage installs every dependency, runs npm run build (the typecheck, plus the Tailwind build when that feature is on) and packs the app with gio build standalone: the Rust server, the whole Node side bundled into worker.js, prebuilt client assets, public/ and gio.toml. The runtime stage copies only that folder into a slim Node image - no node_modules, no build tools.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . .
RUN npm run build && npx gio build standalone --out standalone
# Migrations (drizzle/) are read at runtime: ship them next to the server.
RUN if [ -d drizzle ]; then cp -R drizzle standalone/drizzle; fi
FROM node:${NODE_VERSION}-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
GIO_HOST=0.0.0.0 \
PORT=3000
COPY --from=build /app/standalone ./
# Writable by the app: the page cache and IPC sockets (.gio) and data/.
RUN mkdir -p .gio data && chown -R node:node .gio data
USER node
EXPOSE 3000
# /_gio/health answers 200 whenever the Rust server is up; nodeReady says
# whether a Node worker is too (false while every worker is restarting).
# Needs [health] enabled = true (the default); with it off, fetch a page of
# your own here instead.
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/_gio/health').then((r) => r.json()).then((health) => process.exit(health.nodeReady === true ? 0 : 1), () => process.exit(1))"]
CMD ["node", "run.mjs"]- The install lines follow your package manager (pnpm, yarn and bun through corepack or npm); the version shown is npm's.
GIO_HOST=0.0.0.0makes the server listen on the container's interface andPORTsets the port; both overridegio.toml.- The app files stay owned by root, so the process cannot modify its own code. It can write only where the server needs to:
.gio/(page cache, IPC sockets) anddata/(SQLite, uploads). - The health check calls
/_gio/health, which the Rust server answers with200as long as it runs. The check passes only when itsnodeReadyfield istrue- a Node worker is up - so a container whose worker keeps crashing is reported unhealthy, not healthy. It needs the endpoint on: with[health] enabled = falseit gets a404and the container stays unhealthy, so change the check to fetch a page of your own ([health] details = falsekeepsnodeReadyand works as is). - The server binary comes from the
@gio.js/server-<platform>package the build stage installs, and nolinux-arm64build is published yet: on ARM machines (Apple Silicon) build forlinux/amd64-docker build --platform linux/amd64 ., or theplatformline indocker-compose.yml.
docker-compose.yml
services:
app:
build: .
image: my-app
platform: linux/amd64
ports:
- "3000:3000"
environment:
PORT: 3000
env_file:
- path: .env.production.local
required: false
volumes:
- app-data:/app/data
restart: unless-stopped
volumes:
app-data:Server secrets such as GIO_SESSION_SECRET go in .env.production.local: compose passes it to the container, and both .gitignore and .dockerignore keep it out of git and out of the image. The standalone folder carries no .env files, so runtime variables always come from the environment. GIO_PUBLIC_* values are the exception: they are inlined into the client bundles at build time, from the build context's .env / .env.production.
The app-data volume keeps data/ - the SQLite database of the database feature - across rebuilds.
Without compose
docker build -t my-app .
docker run -p 3000:3000 --env-file .env.production.local -v my-app-data:/app/data my-appBehind a reverse proxy or load balancer, list it in [server] trusted_proxies so client IPs and rate limits see the real visitor (see Deployment).