Deploying
Step-by-step recipes for Docker, Fly.io, Railway, Render, and a plain Linux server behind nginx or Caddy.
A GioJS server is one Rust binary that supervises its Node render worker(s). It needs Node 20 or newer on the host and nothing else: no CDN, no separate image service, no build server. There are two ways to ship it:
- A standalone folder (recommended for containers and platforms).
gio build standalonepackages the server binary, your whole Node side bundled into oneworker.js, the prebuilt client chunks andpublic/into./standalone. It runs withnode run.mjs- nonode_modules, nonpm installon the host. See Standalone Deploys. - From source. Copy the project,
npm ci --omit=dev, andnpm start. There is no build step: routes are discovered and client bundles built when the server starts, andnpm updatestays your upgrade path.
Whichever you pick, the same few facts drive every recipe below:
- Production mode is anything but
NODE_ENV=development.npm startandrun.mjsboth default to production. - The port comes from
GIO_PORT, thenPORT(which Fly.io, Railway, Render, Heroku and Cloud Run set), then[server] port, then3000. The host defaults to0.0.0.0- keep it that way in containers. - Secrets come from the environment - see Environment Variables.
- Health:
GET /_gio/healthanswers 200 with JSON. The port only opens once the first Node worker is up, so a platform's HTTP check passing means the app can render. Every health check on this page needs the endpoint: with[health] enabled = falseit is a404and the platform marks the app unhealthy, so point the check at a page of your own instead ([health] details = falseis fine - it keepsnodeReady). - Behind a proxy (every platform here has one), set
[server] trusted_proxiesso rate limits andreq.ipsee real visitors, and[security] hsts = trueonce the site is HTTPS-only. See Trusting the platform's proxy.
Before going live, work through the production checklist.
Docker
A two-stage image: the first stage installs dependencies and runs gio build standalone; the runtime stage is a slim Node image holding only the standalone folder, run as an unprivileged user.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx gio build standalone --out /standalone
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /standalone ./
RUN mkdir -p .gio/cache && chown node:node /app .gio/cache
USER node
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --start-period=30s CMD node -e "fetch('http://127.0.0.1:' + (process.env.GIO_PORT || process.env.PORT || 3000) + '/_gio/health').then(r => r.json()).then(h => process.exit(h.nodeReady ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "run.mjs"]node_modules
.git
.gio
out
standalone
.env*.local
*.logdocker build -t my-app .
docker run -p 3000:3000 --stop-timeout 20 \
-e GIO_SESSION_SECRET="$GIO_SESSION_SECRET" \
my-app- Writable
.gio/. The server keeps its page and image caches and its IPC sockets under.gio/in the folder it runs from, so the runtime user must own/app. The cache is lost with the container; mount a volume at/app/.gio/cacheto keep it across restarts. - Public variables are baked in.
GIO_PUBLIC_*values are read duringgio build standalone(from the build environment and the committed.env/.env.productionfiles). To vary one per environment, declare it in the build stage (ARG GIO_PUBLIC_API_URLbefore the build command) and pass--build-arg. Server variables are read when the container starts; the image carries no.envfile, and.dockerignorekeeps local secrets out of the build context. - Architecture. The build stage packages the server binary for the platform it runs on. Prebuilt binaries exist for
linux/amd64(glibc and musl) but not yet forlinux/arm64, so on an Apple Silicon Mac or an ARM CI runner build withdocker build --platform linux/amd64. Keep both stages on the same base: an Alpine build stage packages the musl binary, which then needs an Alpine runtime. - Stopping.
run.mjsis PID 1 and forwardsSIGTERM; the server ends open event streams (SSE andtext/event-streamroute handlers), drains requests and streamed downloads for up to 8 seconds (a download still running then is cut off, which the client sees as a failed transfer) and then gives the workers a few more to exit. Docker's default 10-second stop timeout can cut that short - use--stop-timeout 20(Compose:stop_grace_period: 20s). - The
HEALTHCHECKreadsnodeReady, so a container whose only worker is stuck respawning reports unhealthy even though/_gio/healthitself still answers 200. It assumes the port comes fromGIO_PORT,PORTor the default - adjust it ifgio.tomlsets another. It needs[health] enabled = true(the default); with the endpoint off, probe a page of your own instead.
The Docker starter recipe walks through an image like this one. With pnpm or Yarn, swap the lockfile and install command in the first stage.
services:
app:
build: .
ports:
- "3000:3000"
env_file: .env.production.local # server secrets, kept out of the image
volumes:
- gio-cache:/app/.gio/cache
stop_grace_period: 20s
restart: unless-stopped
volumes:
gio-cache:Fly.io
Fly builds the Dockerfile above and runs it on its machines; its proxy terminates TLS and forwards to the port in fly.toml. Set PORT to that same port:
app = "my-app"
primary_region = "fra"
kill_timeout = 20 # seconds to drain before a forced stop (Fly's default is 5)
[env]
PORT = "8080"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 1
[[http_service.checks]]
method = "GET"
path = "/_gio/health"
interval = "15s"
timeout = "5s"
grace_period = "20s"fly launch --no-deploy # creates the app; keep the fly.toml above
fly secrets set GIO_SESSION_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))")"
fly deploy- Keep
kill_timeout. Fly stops a machine withSIGINT, which GioJS drains likeSIGTERM, but force-stops it after 5 seconds by default - less than the drain needs (see Stopping above), so every deploy or scale-down would cut requests off mid-response. - Each machine has its own page cache, on a disk that is reset when the machine is replaced; a machine stopped by
auto_stop_machinesstarts with a cold memory cache. Mount a volume at/app/.gio/cacheif warm restarts matter. - With several machines, purge on-demand revalidations on each one - see Multi-instance deployments.
Railway
Railway builds the Dockerfile in your repository and sets PORT itself. Add a railway.json for the health check and the shutdown grace period:
{
"$schema": "https://railway.com/railway.schema.json",
"build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" },
"deploy": {
"healthcheckPath": "/_gio/health",
"restartPolicyType": "ON_FAILURE",
"drainingSeconds": 20
}
}Set GIO_SESSION_SECRET and your other secrets under the service's Variables, then generate a domain under Settings › Networking. Railway terminates TLS in front of the container.
Keep drainingSeconds (or set the service variable RAILWAY_DEPLOYMENT_DRAINING_SECONDS=20): Railway sends the old deployment SIGTERM and, by default, SIGKILL right after it, which gives GioJS no time to finish in-flight requests on every deploy.
Render
Create a Web Service from the repository with the Docker runtime, or describe it in a render.yaml Blueprint. Render sets PORT (10000 by default) and expects the app on 0.0.0.0, which is GioJS's default:
services:
- type: web
name: my-app
runtime: docker
healthCheckPath: /_gio/health
envVars:
- key: GIO_SESSION_SECRET
generateValue: trueRender terminates TLS and routes traffic to the service over its private network. The filesystem is ephemeral, so the disk cache starts empty on each deploy unless you attach a persistent disk at /app/.gio/cache. On a deploy Render sends the old instance SIGTERM and waits up to 30 seconds by default before SIGKILL, which is enough for GioJS to drain - if you set maxShutdownDelaySeconds, keep it at 20 or more.
Trusting the platform's proxy
On every platform above, connections reach GioJS from the platform's router, not from visitors. Until you trust that router, all visitors share one [[rate_limits]] bucket and req.ip is the router's address. The routers append the visitor to X-Forwarded-For and set X-Forwarded-Proto, but they do not all document the address they connect from - so look once. With trusted_proxies empty, req.ip is the connecting address:
// app/api/whoami/route.ts - temporary: deploy, request it once, delete it
export function GET(req) {
return { peer: req.ip, forwardedFor: req.headers['x-forwarded-for'] ?? null };
}Trust the private range that address belongs to (for example "10.0.0.0/8" or "172.16.0.0/12"), then remove the route:
[server]
trusted_proxies = ["10.0.0.0/8"] # the range your platform's router connects from
accept_request_id = false # the router may pass a client's X-Request-Id through
[security]
hsts = true # the platform terminates TLS; send HSTS yourselfA Linux server with systemd
On a VPS or bare metal, run the standalone folder as a systemd service behind nginx or Caddy, which terminate TLS. GioJS listens on 127.0.0.1 only, so nothing reaches it around the proxy.
1. Build and copy the folder
# On your machine or in CI. From macOS or Windows, add --target linux-x64
# (after: npm i @gio.js/server-linux-x64 --force).
npx gio build standalone
# Copy it, keeping the server's cache and local env file in place
rsync -a --delete --exclude .gio/cache --exclude '.env*.local' \
standalone/ [email protected]:/srv/my-app/The server needs Node 20 or newer and an unprivileged user that owns the folder:
sudo useradd --system --home /srv/my-app --shell /usr/sbin/nologin gio
sudo chown -R gio:gio /srv/my-app2. Configure
# /srv/my-app/gio.toml (your project's gio.toml, copied by the build)
[server]
host = "127.0.0.1" # reachable only through the proxy
trusted_proxies = ["127.0.0.1", "::1"] # the proxy runs on this machine
[security]
hsts = true # once the site is HTTPS-only# /etc/my-app.env - secrets, readable by root only (systemd reads it before dropping privileges)
GIO_SESSION_SECRET=...
DATABASE_URL=postgres://...sudo chmod 600 /etc/my-app.env3. The systemd unit
# /etc/systemd/system/my-app.service
[Unit]
Description=my-app (GioJS)
After=network-online.target
Wants=network-online.target
[Service]
User=gio
Group=gio
WorkingDirectory=/srv/my-app
ExecStart=/usr/bin/node /srv/my-app/run.mjs
Environment=NODE_ENV=production
EnvironmentFile=/etc/my-app.env
Restart=always
RestartSec=2
# SIGTERM to the launcher only: it stops the server, which drains requests
# and then stops its workers. Anything left after the timeout is killed.
KillMode=mixed
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/my-app
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now my-app
journalctl -u my-app -f # logs; GIO_LOG_FORMAT=json for a log shipperAfter each deploy, sudo systemctl restart my-app. The server restarts in a few seconds; for zero-downtime deploys run two instances on different ports behind the proxy and restart them one at a time.
4a. nginx
# /etc/nginx/sites-available/my-app
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 2m; # match gio.toml [server] max_body_bytes (2 MiB default)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSockets
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host; # CSRF and WebSocket origin checks compare against it
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id; # nginx's id becomes GioJS's
proxy_buffering off; # stream SSR, SSE and streamed responses as they render
}
}sudo ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/
sudo certbot --nginx -d example.com # or point the ssl_ lines at your own certificate
sudo nginx -t && sudo systemctl reload nginx- Without
proxy_buffering off, nginx holds streamed pages and server-sent events until they finish - the page still works, but loses the point of streaming. - GioJS already compresses responses; leave nginx's
gzipoff for this location (or turn off[compression]ingio.tomlinstead - not both). - This config opens a fresh upstream connection per request. If you add an
upstreamblock withkeepalive, keep itskeepalive_timeoutbelow 10 seconds - see Behind a reverse proxy.
4b. Caddy
Caddy gets and renews the certificate itself, keeps Host, sets the forwarding headers and ignores spoofed ones:
# /etc/caddy/Caddyfile
example.com {
reverse_proxy 127.0.0.1:3000 {
header_up X-Request-Id {http.request.uuid} # Caddy passes a client's own id through otherwise
}
}The gio.toml from step 2 applies unchanged. Caddy does not send HSTS on its own either, so keep [security] hsts = true.
Without a standalone build
To run from source instead, copy the project (without node_modules), install production dependencies on the server and point the unit at the giojs-server launcher:
cd /srv/my-app && npm ci --omit=devExecStart=/srv/my-app/node_modules/.bin/giojs-serverEverything else - the environment file, KillMode=mixed, the proxy - stays the same. The first start after a deploy takes a little longer while the server builds the client bundles.
Other targets
- Kubernetes: Deployment, Service, Ingress and HPA manifests in docs/deployment/kubernetes.md. Use the Docker image above and a readiness probe on
/_gio/health. - Windows Server: run it as a service with NSSM - docs/deployment/windows-nssm.md.
- Static hosts (Cloudflare Pages, GitHub Pages, Netlify, S3): when the site needs no server features, export it to plain HTML instead.
For proxies, load balancers, worker sizing, graceful shutdown and running several instances, see the deployment reference.