|
| 1 | +--- |
| 2 | +title: "Self-Hosting SonicJS with Docker: A Complete Guide" |
| 3 | +description: "Run SonicJS on your own server, VPS, or private cloud using Docker. No Cloudflare account required — just Docker, a volume, and two environment variables." |
| 4 | +keywords: |
| 5 | + - "self-host sonicjs" |
| 6 | + - "sonicjs docker" |
| 7 | + - "sonicjs vps deployment" |
| 8 | + - "sonicjs node.js self-host" |
| 9 | + - "headless cms self-hosted" |
| 10 | + - "docker cms deployment" |
| 11 | +category: "tutorials" |
| 12 | +tags: |
| 13 | + - "deployment" |
| 14 | + - "docker" |
| 15 | + - "self-hosting" |
| 16 | + - "node.js" |
| 17 | + - "tutorial" |
| 18 | +publishedAt: "2026-07-01" |
| 19 | +author: |
| 20 | + name: "SonicJS Team" |
| 21 | + twitter: "sonicjs" |
| 22 | + github: "lane711/sonicjs" |
| 23 | +featured: false |
| 24 | +featuredImage: |
| 25 | + url: "/images/blog/self-host-sonicjs-with-docker/hero.png" |
| 26 | + alt: "Isometric illustration of a server rack with a Docker whale logo serving SonicJS content to browsers" |
| 27 | + width: 1792 |
| 28 | + height: 1024 |
| 29 | +openGraph: |
| 30 | + type: "article" |
| 31 | + title: "Self-Hosting SonicJS with Docker: A Complete Guide" |
| 32 | + description: "Run SonicJS on your own infrastructure — Docker Compose, SQLite persistence, first-boot seed, and production hardening in one guide." |
| 33 | +--- |
| 34 | + |
| 35 | +# Self-Hosting SonicJS with Docker: A Complete Guide |
| 36 | + |
| 37 | +<TLDRBox> |
| 38 | +**TL;DR** — SonicJS ships a production-ready Docker image. Three commands get you running: |
| 39 | + |
| 40 | +```bash |
| 41 | +docker build -t sonicjs . |
| 42 | +docker run -d -p 3000:3000 -v $(pwd)/data:/app/data \ |
| 43 | + -e JWT_SECRET=$(openssl rand -base64 32) \ |
| 44 | + -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \ |
| 45 | + sonicjs |
| 46 | +docker exec sonicjs npm run reset |
| 47 | +``` |
| 48 | + |
| 49 | +Default admin: `admin@sonicjs.com` / `sonicjs!` — **change it after first login**. |
| 50 | + |
| 51 | +**Key facts:** |
| 52 | +- Single SQLite file in a Docker volume — no external database required |
| 53 | +- `npm run reset` seeds admin + RBAC on first boot; safe to run again (idempotent) |
| 54 | +- Supports Node.js 20+, Bun, and Docker |
| 55 | +- Full self-host — zero dependency on Cloudflare |
| 56 | +</TLDRBox> |
| 57 | + |
| 58 | +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. |
| 59 | + |
| 60 | +This guide walks through running SonicJS on your own infrastructure from first container to production-hardened deployment. |
| 61 | + |
| 62 | +## How Self-Hosting Works |
| 63 | + |
| 64 | +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: |
| 65 | + |
| 66 | +| Cloudflare | Self-Host | |
| 67 | +|---|---| |
| 68 | +| D1 (SQLite at the edge) | `better-sqlite3` (local SQLite file) | |
| 69 | +| R2 (object storage) | Local filesystem (`./data/media/`) | |
| 70 | +| KV (key-value cache) | In-memory LRU cache | |
| 71 | +| Workers runtime | Node.js `@hono/node-server` | |
| 72 | + |
| 73 | +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. |
| 74 | + |
| 75 | +## Prerequisites |
| 76 | + |
| 77 | +- **Docker 20+** (or Node.js 20+ if running without Docker) |
| 78 | +- The SonicJS source repo — `git clone https://github.com/lane711/sonicjs.git` |
| 79 | +- `openssl` (for generating secrets — `brew install openssl` on macOS, available by default on Linux) |
| 80 | + |
| 81 | +## Option A: Docker (Recommended) |
| 82 | + |
| 83 | +### Step 1: Build the Image |
| 84 | + |
| 85 | +From the repo root: |
| 86 | + |
| 87 | +```bash |
| 88 | +docker build -t sonicjs . |
| 89 | +``` |
| 90 | + |
| 91 | +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. |
| 92 | + |
| 93 | +### Step 2: Run the Container |
| 94 | + |
| 95 | +Create a `data/` directory for the SQLite volume, then start the container: |
| 96 | + |
| 97 | +```bash |
| 98 | +mkdir -p data |
| 99 | + |
| 100 | +docker run -d \ |
| 101 | + --name sonicjs \ |
| 102 | + -p 3000:3000 \ |
| 103 | + -v $(pwd)/data:/app/data \ |
| 104 | + -e JWT_SECRET=$(openssl rand -base64 32) \ |
| 105 | + -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \ |
| 106 | + sonicjs |
| 107 | +``` |
| 108 | + |
| 109 | +The container is healthy when logs show `SonicJS self-host listening on :3000`. |
| 110 | + |
| 111 | +```bash |
| 112 | +docker logs sonicjs |
| 113 | +# Server listening on http://0.0.0.0:3000 |
| 114 | +``` |
| 115 | + |
| 116 | +### Step 3: Seed the First Admin |
| 117 | + |
| 118 | +The database is auto-migrated at startup, but the admin account doesn't exist yet. Run the seed script inside the container: |
| 119 | + |
| 120 | +```bash |
| 121 | +docker exec sonicjs npm run reset |
| 122 | +``` |
| 123 | + |
| 124 | +Output: |
| 125 | + |
| 126 | +``` |
| 127 | +[seed] Admin user created: |
| 128 | + Email: admin@sonicjs.com |
| 129 | + Password: sonicjs! |
| 130 | + ⚠ Change this password after first login! |
| 131 | +``` |
| 132 | + |
| 133 | +The `reset` command is idempotent — run it as many times as you like. If an admin already exists, it exits silently. |
| 134 | + |
| 135 | +### Step 4: Verify |
| 136 | + |
| 137 | +```bash |
| 138 | +# Health endpoint |
| 139 | +curl http://localhost:3000/health |
| 140 | +# → {"status":"ok"} |
| 141 | + |
| 142 | +# Open the admin UI |
| 143 | +open http://localhost:3000/admin |
| 144 | +``` |
| 145 | + |
| 146 | +Sign in with `admin@sonicjs.com` / `sonicjs!` and change the password immediately. |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +## Option B: Docker Compose |
| 151 | + |
| 152 | +For a reproducible, version-controlled setup, use Docker Compose: |
| 153 | + |
| 154 | +```yaml |
| 155 | +# docker-compose.yml |
| 156 | +version: "3.9" |
| 157 | + |
| 158 | +services: |
| 159 | + sonicjs: |
| 160 | + build: . |
| 161 | + container_name: sonicjs |
| 162 | + restart: unless-stopped |
| 163 | + ports: |
| 164 | + - "3000:3000" |
| 165 | + volumes: |
| 166 | + - sonicjs_data:/app/data |
| 167 | + environment: |
| 168 | + JWT_SECRET: "${JWT_SECRET}" |
| 169 | + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" |
| 170 | + SONICJS_ADMIN_EMAIL: "${SONICJS_ADMIN_EMAIL:-admin@sonicjs.com}" |
| 171 | + SONICJS_ADMIN_PASSWORD: "${SONICJS_ADMIN_PASSWORD:-sonicjs!}" |
| 172 | + healthcheck: |
| 173 | + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] |
| 174 | + interval: 30s |
| 175 | + timeout: 10s |
| 176 | + retries: 3 |
| 177 | + start_period: 10s |
| 178 | + |
| 179 | +volumes: |
| 180 | + sonicjs_data: |
| 181 | +``` |
| 182 | +
|
| 183 | +Create a `.env` file (never commit this): |
| 184 | + |
| 185 | +```bash |
| 186 | +JWT_SECRET=$(openssl rand -base64 32) |
| 187 | +BETTER_AUTH_SECRET=$(openssl rand -base64 32) |
| 188 | +SONICJS_ADMIN_EMAIL=admin@yourdomain.com |
| 189 | +SONICJS_ADMIN_PASSWORD=a-strong-password-here |
| 190 | +``` |
| 191 | + |
| 192 | +Start and seed: |
| 193 | + |
| 194 | +```bash |
| 195 | +docker compose up -d |
| 196 | +docker compose exec sonicjs npm run reset |
| 197 | +``` |
| 198 | + |
| 199 | +--- |
| 200 | + |
| 201 | +## Option C: Node.js Directly |
| 202 | + |
| 203 | +If you prefer running without Docker — bare metal, a VM, or a PaaS like Railway or Render: |
| 204 | + |
| 205 | +```bash |
| 206 | +# Install dependencies |
| 207 | +npm install |
| 208 | +
|
| 209 | +# Build the core package |
| 210 | +npm run build:core |
| 211 | +
|
| 212 | +# Start the self-host server |
| 213 | +cd my-sonicjs-app && npm run self-host |
| 214 | +``` |
| 215 | + |
| 216 | +Then seed: |
| 217 | + |
| 218 | +```bash |
| 219 | +cd my-sonicjs-app && npm run reset |
| 220 | +``` |
| 221 | + |
| 222 | +The `self-host` script starts `@hono/node-server` on port 3000. Set `PORT` to override. |
| 223 | + |
| 224 | +For Bun: |
| 225 | + |
| 226 | +```bash |
| 227 | +cd my-sonicjs-app && npm run self-host:bun |
| 228 | +``` |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +## Environment Variables |
| 233 | + |
| 234 | +| Variable | Required | Default | Description | |
| 235 | +|---|---|---|---| |
| 236 | +| `JWT_SECRET` | Yes | — | Signs session JWTs. Generate with `openssl rand -base64 32`. | |
| 237 | +| `BETTER_AUTH_SECRET` | Yes | — | Better Auth internal signing key. Same generation. | |
| 238 | +| `PORT` | No | `3000` | HTTP port to listen on. | |
| 239 | +| `SONICJS_DB_PATH` | No | `./data/sonicjs.db` | Path to the SQLite database file. | |
| 240 | +| `SONICJS_ADMIN_EMAIL` | No | `admin@sonicjs.com` | Admin email used by `npm run reset`. | |
| 241 | +| `SONICJS_ADMIN_PASSWORD` | No | `sonicjs!` | Admin password used by `npm run reset`. | |
| 242 | + |
| 243 | +**Never use the default `JWT_SECRET` or `BETTER_AUTH_SECRET` in production.** These protect every session token in your system. |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## Database Persistence |
| 248 | + |
| 249 | +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: |
| 250 | + |
| 251 | +```bash |
| 252 | +# Named volume (recommended — managed by Docker) |
| 253 | +-v sonicjs_data:/app/data |
| 254 | +
|
| 255 | +# Bind mount (easier to inspect/backup) |
| 256 | +-v $(pwd)/data:/app/data |
| 257 | +``` |
| 258 | + |
| 259 | +Without a volume, the database is lost when the container stops. |
| 260 | + |
| 261 | +### Backups |
| 262 | + |
| 263 | +Backing up SQLite is as simple as copying the file: |
| 264 | + |
| 265 | +```bash |
| 266 | +# From host (bind mount) |
| 267 | +cp data/sonicjs.db "backups/sonicjs-$(date +%Y%m%d-%H%M%S).db" |
| 268 | +
|
| 269 | +# From inside the container |
| 270 | +docker exec sonicjs sqlite3 /app/data/sonicjs.db ".backup '/app/data/backup.db'" |
| 271 | +docker cp sonicjs:/app/data/backup.db ./backups/ |
| 272 | +``` |
| 273 | + |
| 274 | +SQLite's WAL mode means live backups are consistent — no need to stop the server. |
| 275 | + |
| 276 | +--- |
| 277 | + |
| 278 | +## Production Hardening |
| 279 | + |
| 280 | +### Reverse Proxy with HTTPS |
| 281 | + |
| 282 | +Never expose port 3000 directly. Put SonicJS behind nginx or Caddy: |
| 283 | + |
| 284 | +**Caddy** (automatic HTTPS via Let's Encrypt — the simplest option): |
| 285 | + |
| 286 | +``` |
| 287 | +# Caddyfile |
| 288 | +cms.yourdomain.com { |
| 289 | + reverse_proxy sonicjs:3000 |
| 290 | +} |
| 291 | +``` |
| 292 | +
|
| 293 | +**nginx:** |
| 294 | +
|
| 295 | +```nginx |
| 296 | +server { |
| 297 | + listen 443 ssl; |
| 298 | + server_name cms.yourdomain.com; |
| 299 | +
|
| 300 | + ssl_certificate /etc/ssl/certs/sonicjs.crt; |
| 301 | + ssl_certificate_key /etc/ssl/private/sonicjs.key; |
| 302 | +
|
| 303 | + location / { |
| 304 | + proxy_pass http://localhost:3000; |
| 305 | + proxy_set_header Host $host; |
| 306 | + proxy_set_header X-Real-IP $remote_addr; |
| 307 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 308 | + proxy_set_header X-Forwarded-Proto $scheme; |
| 309 | + } |
| 310 | +} |
| 311 | +``` |
| 312 | + |
| 313 | +### Security Checklist |
| 314 | + |
| 315 | +- [ ] `JWT_SECRET` and `BETTER_AUTH_SECRET` are random 256-bit values — not defaults |
| 316 | +- [ ] Admin password changed from `sonicjs!` |
| 317 | +- [ ] HTTPS enforced — container not exposed directly on port 80/443 |
| 318 | +- [ ] SQLite volume is on a persistent disk with regular backups |
| 319 | +- [ ] Container running as non-root (the Dockerfile already does this via `USER node`) |
| 320 | +- [ ] `SONICJS_ADMIN_PASSWORD` env var is in `.env`, not in `docker-compose.yml` committed to git |
| 321 | + |
| 322 | +### Container Hardening |
| 323 | + |
| 324 | +The production Dockerfile already: |
| 325 | +- Uses a multi-stage build (no build tools in the final image) |
| 326 | +- Runs as `node` user (non-root) |
| 327 | +- Produces a minimal Alpine image (~120 MB) |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +## Updating SonicJS |
| 332 | + |
| 333 | +```bash |
| 334 | +git pull origin main |
| 335 | +docker build -t sonicjs . |
| 336 | +docker stop sonicjs && docker rm sonicjs |
| 337 | + |
| 338 | +docker run -d \ |
| 339 | + --name sonicjs \ |
| 340 | + -p 3000:3000 \ |
| 341 | + -v $(pwd)/data:/app/data \ |
| 342 | + -e JWT_SECRET="$JWT_SECRET" \ |
| 343 | + -e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ |
| 344 | + sonicjs |
| 345 | +``` |
| 346 | + |
| 347 | +New migrations run automatically at startup. No manual migration step needed. |
| 348 | + |
| 349 | +With Docker Compose: |
| 350 | + |
| 351 | +```bash |
| 352 | +git pull origin main |
| 353 | +docker compose build |
| 354 | +docker compose up -d |
| 355 | +``` |
| 356 | + |
| 357 | +--- |
| 358 | + |
| 359 | +## Troubleshooting |
| 360 | + |
| 361 | +**`[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`. |
| 362 | + |
| 363 | +**`/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`. |
| 364 | + |
| 365 | +**Port 3000 already in use** — Change the host port: `-p 8080:3000`. |
| 366 | + |
| 367 | +**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. |
| 368 | + |
| 369 | +**Media uploads not persisting** — Ensure the volume includes the `media/` subdirectory: `-v $(pwd)/data:/app/data`. Uploads land in `/app/data/media/`. |
| 370 | + |
| 371 | +--- |
| 372 | + |
| 373 | +## When to Use Self-Hosting vs. Cloudflare |
| 374 | + |
| 375 | +| | Self-Hosting | Cloudflare | |
| 376 | +|---|---|---| |
| 377 | +| **Data sovereignty** | Full control | Data on Cloudflare's network | |
| 378 | +| **Global latency** | Depends on host location | 300+ edge locations, sub-50 ms globally | |
| 379 | +| **Scale** | Limited by single server | Effectively unlimited | |
| 380 | +| **Cost at scale** | Predictable VPS cost | Pay-per-request | |
| 381 | +| **Setup complexity** | Docker + reverse proxy | Wrangler config | |
| 382 | +| **Cold starts** | None (persistent process) | None (Workers are always warm) | |
| 383 | +| **Backups** | Manual (SQLite file copy) | D1 automated backups | |
| 384 | + |
| 385 | +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](/deployment) is the better fit. |
| 386 | + |
| 387 | +--- |
| 388 | + |
| 389 | +## Next Steps |
| 390 | + |
| 391 | +- **[Self-Hosting reference docs](/self-hosting)** — full configuration reference, all env vars, adapter internals |
| 392 | +- **[Production Deployment on Cloudflare](/deployment)** — if you want global edge distribution instead |
| 393 | +- **[Configuration guide](/configuration)** — all env vars and plugin options |
| 394 | +- **[Security overview](/security)** — CORS, CSRF, rate limiting, and password hashing |
| 395 | + |
| 396 | +Need help? Join the [SonicJS Discord](https://discord.gg/sonicjs) or open an issue on [GitHub](https://github.com/lane711/sonicjs). |
0 commit comments