Guides

Docker Deployment

Containerising an IOServer backend for production.

Docker Deployment

This guide covers building a production Docker image for an IOServer backend and wiring it up with other services.

Dockerfile

# Stage 1 — build
FROM node:24-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src/ ./src/
COPY routes/ ./routes/

RUN npm run build

# Stage 2 — runtime
FROM node:24-alpine

WORKDIR /app

# Non-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S ioapp -u 1001
USER ioapp

COPY --from=builder --chown=ioapp:nodejs /app/dist ./dist
COPY --from=builder --chown=ioapp:nodejs /app/routes ./routes
COPY --from=builder --chown=ioapp:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=ioapp:nodejs /app/package.json ./

ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD wget -qO /dev/null http://127.0.0.1:3000/api/health || exit 1

CMD ["node", "dist/index.js"]
The health check calls /api/health — add a getHealth method to your ApiController that returns { status: "ok" } and register it in your routes JSON file.

Health endpoint

Add a dedicated health route to every production IOServer deployment:

export class ApiController extends BaseController {
  async getHealth(request: any, reply: any): Promise<void> {
    reply.send({ status: "ok", timestamp: new Date().toISOString() });
  }
}
// routes/api.json
[
  { "method": "GET", "url": "/health", "handler": "getHealth" }
]

Environment variables

Configure IOServer from environment variables in your entry point:

// src/index.ts
import { IOServer, LogLevel } from "ioserver";

const server = new IOServer({
  host: process.env.HOST ?? "0.0.0.0",
  port: parseInt(process.env.PORT ?? "3000", 10),
  verbose: (process.env.LOG_LEVEL ?? "ERROR") as LogLevel,
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(",") ?? ["http://localhost:3000"],
    methods: ["GET", "POST"],
  },
  routes: process.env.ROUTES_DIR ?? "./routes",
});

Docker Compose

# compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: ghcr.io/your-org/your-app:latest
    restart: unless-stopped
    environment:
      HOST: "0.0.0.0"
      PORT: "3000"
      LOG_LEVEL: "INFORMATION"
      ALLOWED_ORIGINS: "https://app.example.com"
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:3000/api/health"]
      interval: 30s
      timeout: 5s
      start_period: 10s
      retries: 3
    networks:
      - app-net

networks:
  app-net:
    driver: bridge

With Traefik reverse proxy

services:
  app:
    build: .
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`api.example.com`)"
      - "traefik.http.routers.app.entrypoints=websecure"
      - "traefik.http.routers.app.tls.certresolver=letsencrypt"
      - "traefik.http.services.app.loadbalancer.server.port=3000"
    networks:
      - traefik
      - app-net

networks:
  traefik:
    external: true
  app-net:
    driver: bridge

Serving a frontend SPA

To serve a React/Vue/Svelte frontend from the same container:

# Multi-stage with frontend build
FROM node:24-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

FROM node:24-alpine AS backend-builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json src/ routes/ ./
RUN npm run build

FROM node:24-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S ioapp -u 1001
USER ioapp

COPY --from=backend-builder  --chown=ioapp:nodejs /app/dist        ./dist
COPY --from=backend-builder  --chown=ioapp:nodejs /app/routes      ./routes
COPY --from=backend-builder  --chown=ioapp:nodejs /app/node_modules ./node_modules
COPY --from=backend-builder  --chown=ioapp:nodejs /app/package.json ./
COPY --from=frontend-builder --chown=ioapp:nodejs /frontend/dist   ./public

ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
EXPOSE 3000
CMD ["node", "dist/index.js"]

Then in your entry point:

import path from "path";
const server = new IOServer({
  rootDir: path.join(__dirname, "../public"),
  spaFallback: true, // unknown GET requests return index.html
});

Graceful shutdown

Always handle SIGTERM in production:

process.on("SIGTERM", async () => {
  await server.stop();
  process.exit(0);
});

process.on("SIGINT", async () => {
  await server.stop();
  process.exit(0);
});

server.stop() calls stop() on all watchers before closing the Fastify server and its Socket.IO connection.

Copyright © 2026