Before you start
- A Hetzner Cloud account. You’ll make a new project in it just for this server.
- macOS or Linux for the
tiffinCLI. On Windows, use it inside WSL. - A Next.js app. Next.js 16.2 or later gets the box’s adapter (a cache shared by every instance, client files served by the edge); older versions build and run without it. No app yet? Step 2 makes one.
The server is billed by Hetzner to you. The smallest size a box uses came to about $10 a month before VAT on 10 October 2026, with its IPv4 address and a 40 GB data volume; see server sizes and prices.
1. Get a server with Tiffin on it
A box is one Linux server with Tiffin and every service on it. Both ways below give you the same box, in your own Hetzner account.
Managed: ShipTiffin sets it up ($19 a month)
- In the Hetzner Cloud console, make a new project for ShipTiffin, then Security → API tokens → Generate API token, with Read & Write.
- Go to shiptiffin.com/start. Sign in, pay, and paste the token. Pick a name, a size and a place.
- The box is built in your Hetzner account in about five minutes, at
<name>.shiptiffin.app. Click Open your dashboard, then add a passkey in the dashboard’s Settings. - Make an API key for your computer: Settings › API keys → Create key. It’s shown once.
$ curl -fsSL https://shiptiffin.com/install.sh | sh $ export TIFFIN_URL=https://dashboard.<name>.shiptiffin.app $ export TIFFIN_TOKEN=<key> $ tiffin whoami # shows the key's name
Put the two export lines in your shell profile (~/.zshrc or ~/.bashrc) to keep them. What we can and can’t do with your Hetzner key is in managed boxes.
Self-hosted: the free CLI does it
Make a Read & Write API token in the Hetzner Cloud console (your project → Security → API tokens). Then:
$ curl -fsSL https://shiptiffin.com/install.sh | sh $ export HCLOUD_TOKEN=... $ tiffin up --provider hetzner --name shop --dry-run # what it makes, and the monthly price from Hetzner's price list $ tiffin up --provider hetzner --name shop # a few minutes; ends with the dashboard address and a one-time sign-in link
It makes the server, a 40 GB data volume, a firewall and an SSH key in your Hetzner project. The default is a cax11 (Arm, 2 vCPU, 4 GB) in Falkenstein on Ubuntu 26.04; change it with --type and --location. Open the sign-in link and add a passkey in Settings.
Until you give it a domain, the box answers at https://dashboard.<server IPv4, dots as dashes>.sslip.io, with a real certificate. The computer that ran tiffin up remembers the box, so there is nothing to connect.
2. Add Tiffin to your app
In your app’s folder, run tiffin init. No app yet? Make one first:
$ npx create-next-app@latest hello --yes $ cd hello $ tiffin init
tiffin init writes tiffin.config.ts, plus AGENTS.md and an agent skill so your coding agent knows how to work with the box (see connecting Claude Code, Codex or Cursor). The project is named after the folder:
import { defineConfig } from "@shiptiffin/sdk";
export default defineConfig({
project: "hello",
apps: {
web: { framework: "next" },
},
services: {
postgres: {},
},
});It also adds the SDK your app uses, @shiptiffin/sdk, as a file in vendor/, so no registry is needed: commit vendor/ and run npm install or bun install.
Database, KV, files, email and analytics are always there; list a service only to set its options. No next.config changes are needed. More in apps and deploys.
3. Plan, apply and deploy
$ tiffin plan # lists every step, its risk and why, and a plan hash; nothing changes yet $ tiffin apply --confirm <hash> -m "Set up hello" $ tiffin deploy # builds on the box, waits for the health check, then switches traffic
Nothing changes until you confirm with that exact plan’s hash. tiffin deploy builds on the box, starts the new version, waits for its health check and switches traffic with no dropped requests. A failed build or health check leaves the old version serving.
Your app is then live at https://hello.<box domain>: an app that sets no routes is served at its project’s name. Next.js runs as a long-lived server (next start, on Bun); an app that needs Node.js sets runtime: "node".
4. Logs and rollbacks
$ tiffin logs web $ tiffin logs web -f # follow them $ tiffin rollback web # back to an earlier production deploy (the last 20 are kept)
Every production deploy also keeps an address of its own, so you can open an earlier version before you roll back to it. Error tracking (Sentry-compatible), traces and alerts are on the box too: see monitoring.
5. Use the database
Every project has its own Postgres 18 database. Your app gets DATABASE_URL (through the box’s connection pooler) and DIRECT_DATABASE_URL (straight to Postgres), set for you. Connect with postgres.js (npm install postgres), once per process:
// db.ts
import postgres from "postgres";
const g = globalThis as { __sql?: postgres.Sql };
export const sql = (g.__sql ??= postgres(process.env.DATABASE_URL!, { prepare: false, max: 5, idle_timeout: 20 }));Keep prepare: false on DATABASE_URL: through the pooler, postgres.js can otherwise retry a query with its parameters encoded twice. Drizzle, Kysely and Prisma 7 sit on top of the same drivers.
Migrations
Give the app a release command. It runs once per deploy, after the build and before the new version takes traffic; if it fails, the old version keeps serving.
apps: { web: { framework: "next", release: "bunx drizzle-kit migrate" } }From your computer
$ tiffin sql hello "select now()" # one read-only statement $ tiffin db tunnel hello # localhost:15432 over SSH, with a postgresql:// URL for psql or TablePlus
Postgres listens only inside the box: there is no public address. Backups run every 6 hours, and Postgres restores to any second of the last 7 days. See Postgres, KV and backups.
6. Add sign-in
Add auth to the project’s services, then run tiffin plan and tiffin apply again. The box then runs Better Auth for your app at /api/auth/* on its own hosts, with users and sessions in the project’s own database.
services: {
postgres: {},
auth: { methods: ["email", "magic-link", "passkey", "google"] },
},In Next.js, @shiptiffin/sdk/next/auth follows the Next.js authentication guide: an optimistic check in proxy.ts, the real check next to the data, and Server Actions that sign in. There is no auth route or config to write.
// proxy.ts: only looks for the session cookie (no network)
import { authProxy } from "@shiptiffin/sdk/next/auth";
export const proxy = authProxy({ protect: ["/dashboard/:path*"], signIn: "/sign-in" });// app/lib/dal.ts
import "server-only";
export { getSession, verifySession, requireRole, currentUser } from "@shiptiffin/sdk/next/auth";
// app/dashboard/page.tsx
const { user, organization } = await verifySession(); // signed out: to /sign-in?next=...// app/actions.ts
"use server";
import { signIn, signOut } from "@shiptiffin/sdk/next/auth";
export async function signInAction(_: unknown, form: FormData) {
// email, password, captcha and next from the form; { ok: false, code, message } on failure
return signIn(form, { redirectTo: "/dashboard" });
}
export async function signOutAction() {
await signOut({ redirectTo: "/" });
}- Email sign-in needs a mail provider. Passwords, magic links and reset emails go through your own provider (Resend, Postmark, SES or any SMTP), set once in the dashboard under Settings › Email. Until then production refuses them; passkeys and Google keep working, and previews use a test inbox.
- Google sign-in needs an OAuth app in Google’s console. Add its keys on the project’s Auth → Sign-in settings page, which shows the one redirect URI to register.
- Don’t put your own routes under
/api/auth/: those requests never reach your app.
Sign-up forms, organizations and roles are in sign-in.
7. Use your own domain
Give the app its own name. This is a normal change: you see the plan, then confirm it.
$ tiffin domains add hello --domain example.com --app web --www # lists the DNS records to add: A (and AAAA) to the server, or one CNAME for a subdomain $ tiffin domains list hello # waiting_for_dns, issuing, then live $ tiffin domains check hello example.com # check DNS now instead of waiting
Once the records point at the server, the box gets a Let’s Encrypt certificate in a few seconds and renews it on its own. Plain HTTP redirects to HTTPS.
To move the whole box (dashboard and every app) to your domain instead, point @ and * at the server with two A records, then:
$ tiffin domain check --domain example.com $ tiffin domain set example.com
Apps then live at <project>.example.com. A managed box keeps its shiptiffin.app address as a second name. More in domains.
8. Deploy on every push (optional)
In the dashboard, Settings › Git › Connect GitHub makes a GitHub App for your box, then New project › Import from GitHub picks a repository, its branch and folder. Every push to the branch then deploys, and every pull request gets a preview at its own address with its own copy of the database, removed when the pull request closes.
Prefer pushing to the box itself? Run tiffin git-remote --add, then git push tiffin main. See deploy from GitHub.
What you get vs a plain VPS + Docker
The same Hetzner server, set up by hand with Docker, works too. Here is what you’d build yourself.
| Tiffin box | VPS + Docker, by hand | |
|---|---|---|
| HTTPS | Certificates issued and renewed for every app, preview and domain | A proxy you add and configure (Caddy, Traefik, nginx) |
| Postgres | One database per project, behind a pooler, with branches | A container you configure, tune and upgrade |
| Backups | Every 6 hours; Postgres to any second of the last 7 days | Scripts you write, schedule and test |
| Deploys | Health-checked switch with no dropped requests; rollback to the last 20 | Yours to build, or a restart with a gap |
| Previews | One per pull request, with its own database copy | More tooling |
| Sign-in | Passwords, magic links, passkeys, Google and more, on the box | A library you add, host and keep patched |
| The server itself | SSH keys only, a firewall, CrowdSec, daily security updates | Yours to set up |
| One app using too much | A limit per project: memory, CPU, database and KV | Container limits you set by hand |
| Logs, errors, analytics | On the box, nothing to install | More services to run or pay for |
| Cost | The server, plus $19 a month managed or nothing self-hosted | The server |
When a Tiffin box fits
- Several apps that each need a database, sign-in or jobs
- You want deploys, previews and backups without writing them
- You want a coding agent to run the server through plan and apply
When a plain VPS fits better
- One container that already runs the way you like
- An app that needs ports other than HTTP and HTTPS (the box lets in only SSH, HTTP and HTTPS)
- You want to choose and configure every piece yourself
- You need something past pre-1.0 software on one server: Tiffin is pre-1.0, and a hardware fault means downtime until the server is back