Guides: Deploying

Guides

Deploying

A Grimoire app in production is one Bun process tree: sigil start runs a small coordinator that listens on your port, and one or more worker processes behind it that render pages. This guide covers what to run, what to put in front of it, and when to add workers. The last tutorial step is a shorter, hands-on version.

Build, then start

bun run build
NODE_ENV=production bun run start -- --no-build

sigil build compiles every route for the browser into public/__grimoire__/. sigil start builds too unless given --no-build; build once, during deployment, and start with --no-build.

Set NODE_ENV=production. With it, unexpected errors never show their details to visitors.

Ports and hosts

The coordinator listens on port and host from sigil.config.ts. Workers listen on 127.0.0.1 only, on the ports after it: with port: 3000 and one worker, the worker takes 3001; with three, 3001 to 3003. Keep those ports free.

Let the environment choose, so the same code runs locally and in a container:

sigil.config.ts
import type { GrimoireConfig } from "@sigil-dev/grimoire";

export default {
	port: Number(process.env.PORT ?? 3000),
	host: process.env.HOST ?? "localhost",
} satisfies GrimoireConfig;

In a container, set HOST=0.0.0.0. On a server with a reverse proxy on the same machine, leave it on localhost so only the proxy can reach the app.

Behind a reverse proxy

Put a proxy that handles TLS in front, such as Caddy, nginx, or your platform's load balancer. With Caddy it's one block, and WebSockets pass through without further setup:

Caddyfile
books.example.com {
	encode zstd gzip
	reverse_proxy localhost:3000
}

Things to know behind a proxy:

  • The app sees the proxy's Host header, so url.host in loads is the public host name. Caddy passes it on by default.
  • TLS ends at the proxy, so url.protocol is http:. Build absolute URLs from a configured origin (an environment variable such as PUBLIC_ORIGIN) rather than from url when the scheme matters.
  • The connection comes from the proxy, so the visitor's address is in the X-Forwarded-For header the proxy sets.

Docker

Dockerfile
FROM oven/bun:1-alpine
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
RUN bun run build

ENV NODE_ENV=production HOST=0.0.0.0
EXPOSE 3000
CMD ["bun", "run", "start", "--", "--no-build"]

With a .dockerignore for node_modules, .grimoire, public/__grimoire__ and local databases. Keep databases and uploads on a volume, and point the app at them with environment variables. docker stop sends SIGTERM, which shuts the coordinator and its workers down cleanly.

systemd

Without containers, a unit keeps the server running and restarts it if the coordinator itself exits:

/etc/systemd/system/books.service
[Unit]
Description=Reading list
After=network.target

[Service]
WorkingDirectory=/srv/books
Environment=NODE_ENV=production
ExecStart=/usr/local/bin/bun run start -- --no-build
Restart=on-failure
User=books

[Install]
WantedBy=multi-user.target

Workers

The coordinator restarts a worker that crashes: after a second, or longer if it keeps crashing on startup. While it's down, requests get a 502.

One worker is the default. For more, pass --scale:

bun run start -- --no-build --scale full=4

full workers handle everything, and requests are spread across them in turn. Workers can also specialise:

ModeHandles
fulleverything
frontendpages
api+server.ts routes
wsWebSocket connections

--scale frontend=2,api=2,ws=1 runs five workers. A WebSocket connection goes to a ws or full worker picked from the visitor's cookies, so a returning visitor lands on the same one.

Workers help when rendering is CPU-bound. Each is a full Bun process, so start with one per core at most, and measure.

Edit this page