Starter for full-stack web development with Node.js deployed to Hetzner. Learn it, change it, use it as you wish.
JS and TS ecosystem suffers from extreme churn, making migrations between major framework releases extremely challenging, often forcing 'a big rewrite' on app developers every 3-5 years. If you're playing long-term game, it's better and safer to build on top of stable and boring foundation. Moreover, the SPA ecosystem, and frameworks like Next and SvelteKit, are complex beasts with too much hidden "magic" under the hood. That magic works until it doesn't. For the problem of sending data over HTTP to and from the database, I find that complexity hard to justify. By making certain architectural trade-offs, such as embracing hypermedia systems and ditching unneccessary abstractions, it's possible to eliminate all that accidental complexity.
I don't think it's reasonable 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, I prefer building 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) and can be split into multiple self-contained systems for scalability, if necessary. Since every self-contained system includes a UI, integration can be achieved through dynamic inclusion using iframes or server-side includes at the reverse-proxy level. Simple hyperlinks can be utilized to navigate between systems.
Loosely coupled, distributed architectures are challenging to operate, which is why they are better suited for running on the cloud. This is one of the reasons why cloud providers advocate for such architectures. On the other hand, monolithic, self-contained architectures diminish the benefit of running on PaaS or cloud hyperscalers that are opaque and insanely expensive abstractions over good old servers. Thanks to deliberate architectural simplification, I can easily run my apps on a single Hetzner VPS. I created a number of custom scripts for database migrations, zero-downtime deployments, and infrastructure provisioning (I find Terraform state management a hassle and HCL syntax too restrictive). Don't shy away from coding a bit yourself.
As a guiding principles, I bet on stability, simplicity, and fewer abstractions. As a result, I made the following tech choices:
- Node (22+)
- JS (you don't need TS for large-scale apps if you write tests; Rails is a great example)
- Express.js
- Edge.js for templating
- Htmx for SPA experience
- Vanilla.js because all heavy lifting like state and routing is done on the server, we're not building client-heavy app like spreadsheets, and JS today is much better than most people think (see Low JS, Designed to Last, and You Might Not Need jQuery)
- CSS for styling w/o frameworks and build tools (see Do I Need CSS Framework?)
- Playwright for E2E tests
- SQLite with better-sqlite3 for DB access w/o ORMs and query builders (nobody switches databases anyway)
- Litestream for streaming DB replication
By looking into the code, you'll discover lesser-known gems such as staticify
that add hash suffix to static assets for CDN. Generally, the dependencies are minimal, giving a refreshing feel after dealing with bloated frameworks.
Simplicity is achieved through reduction, not addition. We can build and ship straight from local dev by saying no to Docker and external CI servers. By following the #1 rule of distributing systems (don't distribute) and choosing SQLite, we achieve dev/prod environment parity. By eliminating indirection and build tools we can quickly spin up a local dev server and run all tests in parallel. Simplicity is great.
Create <project_dir>/.env
with the following content:
# Path to a public key used for accessing your VPS. Optional, defaults to ~/.ssh/hetzner.pub
PUBLIC_KEY=<value>
# Your public domain such as sizovs.net. Optional, defaults to <server ip>.nip.io
DOMAIN=<value>
HETZNER_API_TOKEN=<secret goes here> task infra
If you're using a custom app domain, point your DNS records to the IP address of your Hetzner VPS.
SERVER_IP=<server ip> task deploy
🎉 Your app should be publicly available via HTTPS on your custom domain or via <server ip>.nip.io
.
task dev
task test
task db:pull
DB_LOCATION=<db location> npm run repl
For transactions that contain multiple statements where the first statement is not an INSERT, UPDATE, or DELETE, it is important to run the transaction as .immediate()
. This ensures that SQLite will queue the write if other writes are in progress, respecting the busy_timeout
pragma. If you forget to do this, SQLite will run the transaction in a deferred mode. This means it will attempt to acquire a write lock only when it first encounters the INSERT, UPDATE, or DELETE statement and won't respect busy_timeout
if the database is locked for writing, leading to sqlite_busy
errors.
For web analytics I use self-hosted https://plausible.io, but https://goaccess.io is also a great option because it can run on the same server (and it's not subject to ad-blocking).