DEV Community

Cover image for I built one self-hosted boilerplate and now I ship everything on it
Igor Bumba
Igor Bumba

Posted on

I built one self-hosted boilerplate and now I ship everything on it

Every time I started a side project, I rebuilt the same five things before I wrote a single line of the actual idea: auth, a database, file uploads, a deploy pipeline, and TLS. Different domain, same plumbing. By the third project I was copy-pasting my own docker-compose.yml from a folder two repos over and renaming things until they stopped erroring.

So I stopped. I froze that plumbing into one boilerplate — PocketBase + Next.js + Caddy on a single cheap VPS — and now I ship every side project on it. Same shape every time: clone, rename, write the part that's actually new, push.

The thing I care about most isn't the speed, though. It's that I stopped paying for it. A handful of real projects now run on this setup for a few euros a month, total — not a stack of per-service SaaS bills that each want $25 here and $20 there before you've shipped anything. No Vercel seat, no managed Postgres, no Auth-as-a-Service, no object-storage line item.

Here's the whole thing.

Why this stack

The trick that makes the cost collapse is PocketBase. It's a single Go binary that gives you, in one process:

  • Auth — email/password, OAuth, the works, with a real users collection
  • A database — SQLite, with a schema you manage from an admin UI
  • Realtime — subscribe to collection changes over SSE
  • File storage — uploads handled, with on-the-fly thumbnails
  • An admin dashboard — at /_/, for free

That's four or five separate SaaS products collapsed into one binary that runs anywhere and stores everything in a folder. Compared to wiring up Supabase or Firebase, the mental model is tiny: it's one process and one data directory. Back up the directory and you've backed up the entire app — database, uploaded files, auth tokens, all of it.

For the front I use Next.js (App Router, React Server Components) because that's where I'm fastest, and Caddy as the reverse proxy because it gets you automatic HTTPS with zero config — it provisions and renews Let's Encrypt certificates on its own.

And it all lives on one small VPS (Hetzner, in my case, but any box works). Not serverless, not a managed platform — a plain Linux box I can SSH into. That's the part people get nervous about, so let me show you it's less scary than it sounds.

The shape of the boilerplate

Every project has the same three top-level pieces:

my-app/
├── apps/web/          # Next.js (App Router, output: 'standalone')
├── pocketbase/
│   ├── pb_migrations/ # schema, as committed JS — version controlled
│   └── pb_data/       # SQLite + uploaded files — gitignored, the only state
└── infra/             # Caddyfile, deploy + backup scripts, systemd units, Docker
Enter fullscreen mode Exit fullscreen mode

Two ideas in there do most of the work.

pb_data/ is the only stateful thing in the entire system. It's gitignored. Everything else — code, schema, infra — is in the repo and reproducible. That single boundary is what makes the whole stack easy to reason about and trivial to back up.

Schema is code. PocketBase lets you click a schema together in the admin UI, but it can export every change as a timestamped JS migration. I commit those, and PocketBase auto-applies them on startup. So the schema travels with the repo and a fresh box rebuilds itself:

/// <reference path="../pb_data/types.d.ts" />

migrate(
  (app) => {
    // up — create a collection with access rules
    const notes = new Collection({
      type: 'base',
      name: 'notes',
      listRule:   '@request.auth.id != ""',
      viewRule:   '@request.auth.id != ""',
      createRule: '@request.auth.id != ""',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'body',  type: 'editor' },
      ],
    });
    app.save(notes);
  },
  (app) => {
    // down — idempotent rollback
    try { app.delete(app.findCollectionByNameOrId('notes')); } catch {}
  },
);
Enter fullscreen mode Exit fullscreen mode

Those listRule/viewRule/createRule strings are PocketBase's access control. Authorization lives in the data layer, evaluated on every request, instead of being scattered through app middleware. @request.auth.id != "" means "must be logged in"; you can get as specific as @request.auth.role = "admin" || owner = @request.auth.id. For most apps I write almost no custom server code — the rules are the security model.

How Next.js actually talks to PocketBase

This is the part I'd have wanted to read three projects ago, so I'll go slow here.

PocketBase has a JS SDK, but Next.js runs in two worlds — the server (Server Components, route handlers) and the browser — and they need different clients. So the boilerplate has a small lib/ with a clear split.

Shared constants. The server-side URL is read at runtime and never baked into the client bundle:

// lib/pb.ts
export const PB_URL = process.env.PB_URL ?? process.env.NEXT_PUBLIC_PB_URL ?? 'http://localhost:8090';
export const PB_COOKIE = 'pb_auth';
Enter fullscreen mode Exit fullscreen mode

The server client reads the auth cookie out of the request and refreshes the session on each call:

// lib/pb.server.ts
import 'server-only';
import PocketBase from 'pocketbase';
import { cookies } from 'next/headers';
import { PB_URL, PB_COOKIE } from './pb';

export async function getServerPb() {
  const pb = new PocketBase(PB_URL);
  const store = await cookies();
  const cookie = store.get(PB_COOKIE)?.value;
  if (cookie) {
    pb.authStore.loadFromCookie(`${PB_COOKIE}=${cookie}`);
    try {
      if (pb.authStore.isValid) {
        await pb.collection('users').authRefresh();
      }
    } catch {
      pb.authStore.clear();
    }
  }
  return pb;
}
Enter fullscreen mode Exit fullscreen mode

The browser client is a singleton that syncs its auth state to a cookie, and — importantly — talks to its own origin by default:

// lib/pb.client.ts
'use client';
import PocketBase from 'pocketbase';

let _browserPb: PocketBase | null = null;

export function getBrowserPb() {
  if (!_browserPb) {
    // No explicit URL in prod: talk to our own origin and let the proxy
    // route /api & /_/ to PocketBase. Keeps the image domain-agnostic.
    const browserUrl = process.env.NEXT_PUBLIC_PB_URL || window.location.origin;
    _browserPb = new PocketBase(browserUrl);
    _browserPb.authStore.loadFromCookie(document.cookie);
    _browserPb.authStore.onChange(() => {
      document.cookie = _browserPb!.authStore.exportToCookie({ httpOnly: false });
    });
  }
  return _browserPb;
}
Enter fullscreen mode Exit fullscreen mode

Notice window.location.origin. The browser never hardcodes where PocketBase is — it just calls /api/... on the same domain. That's the move that makes the whole thing portable, and it's why the next piece matters so much.

The same-origin Caddy split

Auth is a plain cookie named pb_auth. The browser SDK and the server both read it. For that to Just Work — no CORS, no cross-domain cookie headaches — the Next.js app and PocketBase have to look like one origin. Caddy makes that happen by routing on path:

your-app.com {
  encode zstd gzip

  # PocketBase owns its API + admin UI on the same origin (cookies stay simple).
  # But /api/auth/* is owned by Next.js — exclude it from the PB matcher.
  @pb {
    path /api/* /_/*
    not path /api/auth/*
  }
  reverse_proxy @pb 127.0.0.1:8090

  # Everything else → Next.js.
  reverse_proxy 127.0.0.1:3000

  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}
Enter fullscreen mode Exit fullscreen mode

Read that matcher carefully, because it's the cleverest line in the whole stack: /api/* and /_/* go to PocketBase — except /api/auth/*, which Next.js keeps. Login and logout are my own route handlers (I want to set the cookie with my own flags), but every other data call falls straight through to PocketBase. One domain, one cookie, no CORS, ever.

Login, and "no middleware"

The login handler authenticates against PocketBase and hands the browser back a cookie:

// app/api/auth/login/route.ts
const pb = new PocketBase(PB_URL);
await pb.collection('users').authWithPassword(email, password);

// Not httpOnly on purpose: the in-browser SDK needs to read it for realtime + writes.
const cookieValue = pb.authStore.exportToCookie({
  httpOnly: false,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'Lax',
  path: '/',
});

const res = NextResponse.json({ ok: true, user: pb.authStore.record });
res.headers.append('Set-Cookie', cookieValue);
return res;
Enter fullscreen mode Exit fullscreen mode

That httpOnly: false is a deliberate trade-off, not a bug: the browser SDK has to read the token to open realtime subscriptions and make authenticated writes. You pay for it by being disciplined about XSS (no dangerouslySetInnerHTML with untrusted input, a sane CSP), and the rest of your access control lives in PocketBase's collection rules anyway, server-side, where a stolen token still can't exceed the user's permissions.

And I don't use Next.js middleware for auth. I gate pages right where they render:

// lib/auth.ts
export async function requireUser(): Promise<User> {
  const user = await getServerUser();
  if (!user) redirect('/login');
  return user;
}
Enter fullscreen mode Exit fullscreen mode

A protected page or layout just calls await requireUser() at the top. Server Components do the reads (pb.collection('x').getList(...)), Client Components do the mutations and open realtime subscriptions (pb.collection('x').subscribe('*', ...)). For public, unauthenticated writes — a contact form, a signup — there's a third tiny client authed as a superuser that runs only on the server. Three small files, and the auth story is done. No library, no provider, no $0.00-until-it-isn't pricing page.

Shipping it — two ways

Here's where "self-hosting" usually loses people. It shouldn't. I ship the exact same app two ways depending on the project, and both are boring in the good way.

Both rely on one Next.js setting — output: 'standalone', which traces a self-contained Node server so you don't ship node_modules:

// next.config.ts
const nextConfig = { output: 'standalone', reactStrictMode: true };
Enter fullscreen mode Exit fullscreen mode

Way 1 — one Docker container

For anything I want to be portable (run it on a NAS, hand it to someone, move it between hosts), the whole app is a single image: PocketBase + Next.js + Caddy in one container, with supervisord keeping the processes alive and piping all their logs to docker logs.

# supervisord.conf (trimmed)
[program:pocketbase]
command=/usr/local/bin/pocketbase serve --http=127.0.0.1:8090 --dir=/pb/pb_data --migrationsDir=/pb/pb_migrations

[program:web]
command=node /app/apps/web/server.js
environment=NODE_ENV="production",PORT="3000",HOSTNAME="127.0.0.1",PB_URL="http://127.0.0.1:8090"

[program:caddy]
command=/usr/local/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
Enter fullscreen mode Exit fullscreen mode

The compose file is almost nothing, because the image is the app and pb_data is the only volume:

services:
  app:
    image: my-app:amd64
    restart: unless-stopped
    volumes:
      - pb_data:/pb/pb_data
volumes:
  pb_data:
Enter fullscreen mode Exit fullscreen mode
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

That's it — the app is live. Optionally I add a Cloudflare Tunnel (a token env var) so I don't even open a port on the host; Cloudflare terminates TLS and routes straight into the container's Caddy. Great for putting something on the public internet from a box behind a home router.

Way 2 — native on the VPS

For the always-on projects I run PocketBase and Next.js directly under systemd, with Caddy on the host doing HTTPS. No Docker layer, nothing between the binary and the kernel. The PocketBase unit is the whole "database server":

# /etc/systemd/system/pocketbase.service (trimmed)
[Service]
User=pocketbase
ExecStart=/opt/pocketbase/pocketbase serve \
  --http=127.0.0.1:8090 \
  --dir=/var/lib/pocketbase/pb_data \
  --migrationsDir=/opt/pocketbase/pb_migrations
Restart=always
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=full
Enter fullscreen mode Exit fullscreen mode

A one-time bootstrap.sh provisions a bare Ubuntu box (creates the users, installs Node and the PocketBase binary, writes the systemd units and the Caddyfile, sets up the firewall and a backup cron). After that, deploys are a git push away — GitHub Actions runs the same script I run by hand:

# deploy.sh (the essence)
rsync -avz --delete apps/web/.next/standalone/ "$TARGET:/opt/app-web/"
rsync -avz --delete apps/web/.next/static/     "$TARGET:/opt/app-web/.next/static/"
rsync -avz --delete pocketbase/pb_migrations/  "$TARGET:/opt/pocketbase/pb_migrations/"
ssh "$TARGET" "sudo systemctl restart app-web && sudo systemctl restart pocketbase"
Enter fullscreen mode Exit fullscreen mode

Build, rsync the standalone bundle, sync the migrations, restart two services. PocketBase applies any new migrations on the restart. The whole deploy is five lines and a few seconds.

Living with it

A boilerplate is only worth keeping if it survives contact with real use. A few things I've learned:

  • Backups are just a tarball. Because pb_data/ is the entire application state, the backup script is a nightly tar with 14-day retention — that's the whole disaster-recovery story:
  tar -czf "pb_data-$(date +%Y%m%d-%H%M%S).tar.gz" -C /var/lib/pocketbase pb_data
  find /var/backups/pocketbase -name 'pb_data-*.tar.gz' -mtime +14 -delete
Enter fullscreen mode Exit fullscreen mode

The catch nobody tells you: a backup you've never restored isn't a backup. Test the restore. I learned that the boring way.

  • SQLite wants local disk. Do not park pb_data on an NFS/SMB share to feel safe — SQLite's file locking misbehaves over the network and you'll get cursed, intermittent corruption. Local disk, then back up off-box.

  • A bad migration can take the app down on deploy, since they auto-apply on restart. Keeping the down() half real and idempotent has saved me more than once.

  • One box is a single point of failure, and for side projects I've decided I'm fine with that. It restarts on crash, it's backed up nightly, and the cost of "real" HA isn't worth it for projects with tens of users. Know which projects that's not true for.

The honest "what I'd change": continuous SQLite replication (something like Litestream streaming pb_data to object storage) would turn my nightly tarball into near-zero data loss. I keep meaning to wire it in. For now, the tarball has never let me down — partly because I tested the restore.

Takeaways

The boring summary: most side projects don't need a platform. They need auth, a database, file storage, and a way to get on the internet with HTTPS — and you can have all of that in one Go binary, one Next.js app, and one Caddy config on a box that costs less than a coffee a month. Freeze it once. Stop rebuilding it. Spend the saved time on the part that's actually new.

What does your "clone-and-go" stack look like? I'm always looking for the next thing to stop rebuilding.

Top comments (0)