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.