This is my starter template for full-stack web development with Node.js, designed for deployment to Hetzner (or technically, any server). I currently run several production projects using it and couldn't be happier. Feel free to learn from it, modify it, and use it as you wish. 🙏
Web development ecosystem suffers from extreme churn, making migrations between major framework releases extremely challenging, often forcing 'a big rewrite' on developers every 3-5 years. What's trendy today stops compiling tomorrow. I literally can't build multiple Vue projects that I created several years ago. If you're planning long-term, it's wiser to build on top of a stable foundation that takes backward compatibility seriously, and is immune to hype waves. Software should be built to last.
Moreover, the SPA ecosystem, and frameworks like Next and SvelteKit, are complex beasts with too much hidden "magic" under the hood. This magic works until it doesn't. For the problem of sending data over HTTP to and from the database, such complexity is hard to justify. By making certain architectural trade-offs, such as embracing hypermedia systems and ditching unnecessary abstractions, it's possible to eliminate all that accidental complexity.
I find it unreasonable to split apps prematurely across all axes — 1) vertically into microservices, 2) horizontally into BE and FE, and 3) across 'tiers' with DB running on a separate machine. Instead, start with self-contained, monolithic systems that run on a single server. Such systems can handle 10,000s of requests on a beefy VPS (which is enough for most apps), scale up to the moon, and, if necessary, can be split into multiple self-contained systems for scalability. Navigation between systems can be achieved with simple hyperlinks, and one system can include another using server-side includes or iframes.
Loosely coupled, distributed architectures are challenging to operate, making them better suited for the cloud. This is one reason cloud providers advocate for such architectures. In contrast, monolithic, self-contained architectures reduce the benefits of PaaS and serverless solutions, which are opaque and costly abstractions over servers.
To simplify ops and alleviate tooling fatigue, this project includes custom scripts for database migrations, zero-downtime deployments, and infrastructure provisioning (Terraform state management is a hassle and HCL syntax is too restrictive for my taste). Moreover, the project has built-in error tracking that captures errors on both client and server, and stores them in SQLite. Think of it as a free and lightweight version of Sentry. You can view errors under /admin.
Since low churn, simplicity, and fewer abstractions are the guiding principles, the following tech choices are made:
- JS
- Node (24+)
- Fastify web server
- Htmx for SPA experience
- Template literals for server-side templating
- Plain CSS (with scoped css and style tokens)
- Vanilla JS (can use Surreal for Locality of Behavior)
- Playwright for E2E tests
- SQLite w/o ORMs and query builders
- Litestream for streaming DB replication
- Caddy for zero-downtime deployments and automatic TLS
Simplicity is achieved when there is nothing left to remove. The project is built (actually, there is no build step and no sourcemaps) and shipped straight from the local dev machine, eliminating the need for Docker, artifact repositories, and external CI servers. By following the #1 rule of distributing systems — don't distribute — and choosing SQLite, we achieve parity between development and production environments. By eliminating heavy tools and abstractions we can quickly spin up a local dev server, run all tests in parallel against the real database, and know within seconds if our app works.
npm start
npm test
Make sure your public key is available under ~/.ssh/hetzner.pub.
HETZNER_API_TOKEN=<secret goes here> npm run devops create
If you have a custom domain, set it in the package.json, and point your DNS records to the IP address of your Hetzner VPS. If not set, the default domain will be <server ip>.nip.io
HETZNER_API_TOKEN=<secret goes here> npm run devops deploy
🎉 Your app should be publicly available via HTTPS on your custom domain or via <server ip>.nip.io
.
DB_LOCATION=<db location> npm run repl
Create a .env.production
file in the project directory and the script will copy it to the server.
A traditional front-end/back-end separation via APIs requires developing and maintaining two distinct test suites—one for testing the back-end through the API and another for testing the front-end against a mock API, which can easily fall out of sync with the actual back-end. This is cumbersome and clunky. By forgoing JSON APIs and instead sending HTML over the wire, we streamline the process, allowing us to test-drive a single app at the user level using Playwright.
SQLite is blazing fast, takes backward compatibility seriously, and enables amazing DX. Just use SQLite. This project comes with SQLite preconfigured for production, enabling tens of thousands of concurrent writes, despite SQLite not supporting parallel writes. It’s so fast because locking occurs in memory, and WAL is synced to disk only periodically—delivering performance comparable to Redis.
Since everything runs on a single server, users farther away may experience latency. There are several things you can do:
- Optimistic UI
- Preloading
- CRDT
- Baked Data
- Cloudflare for CDN and caching at the edge with Workers.
- For web analytics, check out Plausible
- For data crunching, check out Metabase, DuckDB, and Evidence.
- For non-trivial web components, check out Vanilla Tailwind Components and Web Awesome.
- For backoffice / data admin, check out sqlite-web
- For Docker fanboys, you can deploy directly from the dev machine w/o a registry thanks to unregistry
- Building the Hundred-Year Web Service
- Choose Boring Technology
- HTML First
- Radically Straightforward
- Reasonable System for JavaScript Structure
- Styling CSS without losing your sanity
- The Grug Brained Developer
- We're breaking up with JavaScript frontends
- Web Native Apps
- You Might Not Need JS
By default, the setup replicates the database every minute to the mounted Hetzner volume. For better safety and faster recovery, I recommend reconfiguring Litestream to replicate to an S3-compatible storage (e.g., Cloudflare R2). This can be done in deploy.sh
:
replicas:
- type: s3
endpoint: <R2_BACKUP_ENDPOINT>
bucket: <R2_BACKUP_BUCKET>
access-key-id: <R2_BACKUP_KEY>
secret-access-key: <R2_BACKUP_SECRET>
It’s a good idea to place the app behind Cloudflare’s proxy. This provides static asset caching (CDN) and free DDoS protection.
Caddy ensures that only visitors with a HTTP header X-I-Am-Admin-Babe
can access /admin. You can override the default value in deploy.sh
:
@admin {
path /admin /admin/*
not header X-I-Am-Admin-Babe *
}