Guide

Deploy a Next.js app to a Hetzner server

From an empty Hetzner account to a Next.js app with its own Postgres database, sign-in, logs and a domain with HTTPS, on one server you own. Every command below is the real one, from the quickstart.

Updated

In short

Put Tiffin on a Hetzner Cloud server: managed through shiptiffin.com/start ($19 a month plus the server), or free with the open-source CLI (tiffin up --provider hetzner). Then, in your Next.js folder, run tiffin init, tiffin plan, tiffin apply and tiffin deploy.

The app is served over HTTPS with its own Postgres database on the same server. Sign-in is one line of config, and your own domain is one command.

Before you start

  • A Hetzner Cloud account. You’ll make a new project in it just for this server.
  • macOS or Linux for the tiffin CLI. 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)

  1. In the Hetzner Cloud console, make a new project for ShipTiffin, then Security → API tokens → Generate API token, with Read & Write.
  2. Go to shiptiffin.com/start. Sign in, pay, and paste the token. Pick a name, a size and a place.
  3. 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.
  4. Make an API key for your computer: Settings › API keys → Create key. It’s shown once.
Connect the CLI to a managed box
$ 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:

Make a box on Hetzner
$ 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:

Make a Next.js app and add Tiffin
$ 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:

tiffin.config.ts
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

Ship it
$ 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

Watch and undo
$ 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
// 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.

tiffin.config.ts
apps: { web: { framework: "next", release: "bunx drizzle-kit migrate" } }

From your computer

Query it
$ 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.

tiffin.config.ts
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
// 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
// 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
// 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.

A domain for the app
$ 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:

A domain for the whole box
$ 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.

A Tiffin box compared with a plain VPS and Docker
Tiffin boxVPS + Docker, by hand
HTTPSCertificates issued and renewed for every app, preview and domainA proxy you add and configure (Caddy, Traefik, nginx)
PostgresOne database per project, behind a pooler, with branchesA container you configure, tune and upgrade
BackupsEvery 6 hours; Postgres to any second of the last 7 daysScripts you write, schedule and test
DeploysHealth-checked switch with no dropped requests; rollback to the last 20Yours to build, or a restart with a gap
PreviewsOne per pull request, with its own database copyMore tooling
Sign-inPasswords, magic links, passkeys, Google and more, on the boxA library you add, host and keep patched
The server itselfSSH keys only, a firewall, CrowdSec, daily security updatesYours to set up
One app using too muchA limit per project: memory, CPU, database and KVContainer limits you set by hand
Logs, errors, analyticsOn the box, nothing to installMore services to run or pay for
CostThe server, plus $19 a month managed or nothing self-hostedThe server
From what Tiffin does to the server, apps, data and concepts.

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

Your Next.js app, on your own server

Managed for $19 a month plus the server, or free to run yourself. Coming from Vercel? Read moving off Vercel.