Tutorials8 min read

Self-Hosting SonicJS with Docker: A Complete Guide

Run SonicJS on your own server, VPS, or private cloud using Docker. No Cloudflare account required β€” just Docker, a volume, and two environment variables.

SonicJS Team

Isometric illustration of a server rack with a Docker whale logo serving SonicJS content to browsers

Self-Hosting SonicJS with Docker: A Complete Guide

TL;DR β€” SonicJS ships a production-ready Docker image. Three commands get you running:

docker build -t sonicjs .
docker run -d -p 3000:3000 -v $(pwd)/data:/app/data \
  -e JWT_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  sonicjs
docker exec sonicjs npm run reset

Default admin: admin@sonicjs.com / sonicjs! β€” change it after first login.

Key facts:

  • Single SQLite file in a Docker volume β€” no external database required
  • npm run reset seeds admin + RBAC on first boot; safe to run again (idempotent)
  • Supports Node.js 20+, Bun, and Docker
  • Full self-host β€” zero dependency on Cloudflare

SonicJS was built for Cloudflare's global edge, but sometimes the right deployment is a VPS you control. Maybe you're on a private network, you need data sovereignty, or you simply prefer owning the full stack. SonicJS supports all of that through its self-host mode β€” the same headless CMS, running on any Node.js server or Docker container.

This guide walks through running SonicJS on your own infrastructure from first container to production-hardened deployment.

How Self-Hosting Works

The Cloudflare deployment binds to platform APIs: D1 for the database, R2 for media, KV for caching. The self-host mode replaces each of those with portable equivalents:

CloudflareSelf-Host
D1 (SQLite at the edge)better-sqlite3 (local SQLite file)
R2 (object storage)Local filesystem (./data/media/)
KV (key-value cache)In-memory LRU cache
Workers runtimeNode.js @hono/node-server

The SQLite file lives in a Docker volume (/app/data/sonicjs.db), so it persists across container restarts. The application code is identical β€” SonicJS detects the runtime and switches the adapters automatically.

Prerequisites

  • Docker 20+ (or Node.js 20+ if running without Docker)
  • The SonicJS source repo β€” git clone https://github.com/lane711/sonicjs.git
  • openssl (for generating secrets β€” brew install openssl on macOS, available by default on Linux)

Option A: Docker (Recommended)

Step 1: Build the Image

From the repo root:

docker build -t sonicjs .

The multi-stage Dockerfile builds the TypeScript and packages only the runtime artifacts into a minimal Alpine image. Build time is typically 60–90 seconds on first run (npm install + esbuild compile); subsequent builds are fast thanks to layer caching.

Step 2: Run the Container

Create a data/ directory for the SQLite volume, then start the container:

mkdir -p data

docker run -d \
  --name sonicjs \
  -p 3000:3000 \
  -v $(pwd)/data:/app/data \
  -e JWT_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  sonicjs

The container is healthy when logs show SonicJS self-host listening on :3000.

docker logs sonicjs
# Server listening on http://0.0.0.0:3000

Step 3: Seed the First Admin

The database is auto-migrated at startup, but the admin account doesn't exist yet. Run the seed script inside the container:

docker exec sonicjs npm run reset

Output:

[seed] Admin user created:
  Email:    admin@sonicjs.com
  Password: sonicjs!
  ⚠ Change this password after first login!

The reset command is idempotent β€” run it as many times as you like. If an admin already exists, it exits silently.

Step 4: Verify

# Health endpoint
curl http://localhost:3000/health
# β†’ {"status":"ok"}

# Open the admin UI
open http://localhost:3000/admin

Sign in with admin@sonicjs.com / sonicjs! and change the password immediately.


Option B: Docker Compose

For a reproducible, version-controlled setup, use Docker Compose:

# docker-compose.yml
version: "3.9"

services:
  sonicjs:
    build: .
    container_name: sonicjs
    restart: unless-stopped
    ports:
      - "3000:3000"
    volumes:
      - sonicjs_data:/app/data
    environment:
      JWT_SECRET: "${JWT_SECRET}"
      BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
      SONICJS_ADMIN_EMAIL: "${SONICJS_ADMIN_EMAIL:-admin@sonicjs.com}"
      SONICJS_ADMIN_PASSWORD: "${SONICJS_ADMIN_PASSWORD:-sonicjs!}"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

volumes:
  sonicjs_data:

Create a .env file (never commit this):

JWT_SECRET=$(openssl rand -base64 32)
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
SONICJS_ADMIN_EMAIL=admin@yourdomain.com
SONICJS_ADMIN_PASSWORD=a-strong-password-here

Start and seed:

docker compose up -d
docker compose exec sonicjs npm run reset

Option C: Node.js Directly

If you prefer running without Docker β€” bare metal, a VM, or a PaaS like Railway or Render:

# Install dependencies
npm install

# Build the core package
npm run build:core

# Start the self-host server
cd my-sonicjs-app && npm run self-host

Then seed:

cd my-sonicjs-app && npm run reset

The self-host script starts @hono/node-server on port 3000. Set PORT to override.

For Bun:

cd my-sonicjs-app && npm run self-host:bun

Environment Variables

VariableRequiredDefaultDescription
JWT_SECRETYesβ€”Signs session JWTs. Generate with openssl rand -base64 32.
BETTER_AUTH_SECRETYesβ€”Better Auth internal signing key. Same generation.
PORTNo3000HTTP port to listen on.
SONICJS_DB_PATHNo./data/sonicjs.dbPath to the SQLite database file.
SONICJS_ADMIN_EMAILNoadmin@sonicjs.comAdmin email used by npm run reset.
SONICJS_ADMIN_PASSWORDNosonicjs!Admin password used by npm run reset.

Never use the default JWT_SECRET or BETTER_AUTH_SECRET in production. These protect every session token in your system.


Database Persistence

The SQLite database lives at SONICJS_DB_PATH (default ./data/sonicjs.db). In Docker, always mount /app/data as a named volume or bind mount:

# Named volume (recommended β€” managed by Docker)
-v sonicjs_data:/app/data

# Bind mount (easier to inspect/backup)
-v $(pwd)/data:/app/data

Without a volume, the database is lost when the container stops.

Backups

Backing up SQLite is as simple as copying the file:

# From host (bind mount)
cp data/sonicjs.db "backups/sonicjs-$(date +%Y%m%d-%H%M%S).db"

# From inside the container
docker exec sonicjs sqlite3 /app/data/sonicjs.db ".backup '/app/data/backup.db'"
docker cp sonicjs:/app/data/backup.db ./backups/

SQLite's WAL mode means live backups are consistent β€” no need to stop the server.


Production Hardening

Reverse Proxy with HTTPS

Never expose port 3000 directly. Put SonicJS behind nginx or Caddy:

Caddy (automatic HTTPS via Let's Encrypt β€” the simplest option):

# Caddyfile
cms.yourdomain.com {
    reverse_proxy sonicjs:3000
}

nginx:

server {
    listen 443 ssl;
    server_name cms.yourdomain.com;

    ssl_certificate     /etc/ssl/certs/sonicjs.crt;
    ssl_certificate_key /etc/ssl/private/sonicjs.key;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Security Checklist

  • JWT_SECRET and BETTER_AUTH_SECRET are random 256-bit values β€” not defaults
  • Admin password changed from sonicjs!
  • HTTPS enforced β€” container not exposed directly on port 80/443
  • SQLite volume is on a persistent disk with regular backups
  • Container running as non-root (the Dockerfile already does this via USER node)
  • SONICJS_ADMIN_PASSWORD env var is in .env, not in docker-compose.yml committed to git

Container Hardening

The production Dockerfile already:

  • Uses a multi-stage build (no build tools in the final image)
  • Runs as node user (non-root)
  • Produces a minimal Alpine image (~120 MB)

Updating SonicJS

git pull origin main
docker build -t sonicjs .
docker stop sonicjs && docker rm sonicjs

docker run -d \
  --name sonicjs \
  -p 3000:3000 \
  -v $(pwd)/data:/app/data \
  -e JWT_SECRET="$JWT_SECRET" \
  -e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \
  sonicjs

New migrations run automatically at startup. No manual migration step needed.

With Docker Compose:

git pull origin main
docker compose build
docker compose up -d

Troubleshooting

[seed] Database not found β€” The container hasn't started yet, or the volume isn't mounted. Wait for Server listening on :3000 in the logs, then re-run docker exec sonicjs npm run reset.

/admin returns 403 after seeding β€” The RBAC grants weren't applied. This usually means npm run reset ran before the DB was migrated. Stop the container, delete data/sonicjs.db, restart, and re-run npm run reset.

Port 3000 already in use β€” Change the host port: -p 8080:3000.

SQLite SQLITE_BUSY errors under load β€” SQLite handles concurrent writes with WAL mode and is fine for most self-hosted workloads. If you're seeing lock contention under heavy write load, consider running multiple read replicas via Litestream or switching to the Cloudflare deployment for horizontal scale.

Media uploads not persisting β€” Ensure the volume includes the media/ subdirectory: -v $(pwd)/data:/app/data. Uploads land in /app/data/media/.


When to Use Self-Hosting vs. Cloudflare

Self-HostingCloudflare
Data sovereigntyFull controlData on Cloudflare's network
Global latencyDepends on host location300+ edge locations, sub-50 ms globally
ScaleLimited by single serverEffectively unlimited
Cost at scalePredictable VPS costPay-per-request
Setup complexityDocker + reverse proxyWrangler config
Cold startsNone (persistent process)None (Workers are always warm)
BackupsManual (SQLite file copy)D1 automated backups

For most teams with data residency requirements, a compliance mandate, or a private intranet deployment, self-hosting is the right call. For public-facing, globally distributed content at scale, the Cloudflare deployment is the better fit.


Next Steps

Need help? Join the SonicJS Discord or open an issue on GitHub.

#deployment#docker#self-hosting#node.js#tutorial

Share this article

Related Articles

Isometric illustration of files flowing from a client device into Cloudflare R2 storage cylinders over glowing blue trajectories
Tutorials

File Uploads with SonicJS and Cloudflare R2

Upload, validate, and serve images, video, and documents with SonicJS and Cloudflare R2 β€” multipart uploads, MIME checks, signed URLs, and image transforms.