GioJSdocs
On this page

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:3000

The 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.

dockerfile
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.0 makes the server listen on the container's interface and PORT sets the port; both override gio.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) and data/ (SQLite, uploads).
  • The health check calls /_gio/health, which the Rust server answers with 200 as long as it runs. The check passes only when its nodeReady field is true - 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 = false it gets a 404 and the container stays unhealthy, so change the check to fetch a page of your own ([health] details = false keeps nodeReady and works as is).
  • The server binary comes from the @gio.js/server-<platform> package the build stage installs, and no linux-arm64 build is published yet: on ARM machines (Apple Silicon) build for linux/amd64 - docker build --platform linux/amd64 ., or the platform line in docker-compose.yml.

docker-compose.yml

text
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

bash
docker build -t my-app .
docker run -p 3000:3000 --env-file .env.production.local -v my-app-data:/app/data my-app

Behind a reverse proxy or load balancer, list it in [server] trusted_proxies so client IPs and rate limits see the real visitor (see Deployment).