How I Build Things

And why

← Back to index

Private server-side app pattern

Apps that need a real backend, kept private — reachable only by me, never the public internet.

systemdPodmanTailscale Serve

Overview

Some things aren't a static HTML tool: they need a persistent process, real backend logic, or state beyond a browser tab. Those run as a systemd-managed service — bare-metal, or a Podman container when the app needs isolation the host can't give it for free — kept entirely private: reachable only by me, never the public internet.

Use case

An app I use — or share with people I already trust with network access — that needs an actual backend: a server process, persistent state, background work a browser can't do alone. Not meant to be public.

When not to use this pattern

Options considered

How to keep it private

This is the actual decision the pattern's name refers to, and the one most likely to change later — worth recording properly rather than just asserting today's pick:

Whether to containerize at all

A separate, orthogonal decision — not an either/or across the board, both shapes are accepted, chosen per app:

Both converge on the same operational shape below — systemctl start/enable, logs via journald, a restart policy for free. The trade-off is isolation and portability against nothing standing between the process and the OS; nothing about this pattern forces the choice one way.

Docker (when an app is containerized)

Current recommendation

Tailscale, chosen over the alternatives above mainly because it's the easiest to get up and running — not a deeper claim that it beats Cloudflare Tunnel or a self-hosted WireGuard on their merits, just the one that took the least setup. Specifically Serve rather than Funnel: reachable by tailnet hostname from any of my own devices, invisible to anything not on the tailnet. Deliberately not Funnel — nothing here is meant to be public.

Whichever shape an app needs, it ends up as a systemd unit: a plain .service for a bare-metal app, or a quadlet (a .container file) for a containerized one — systemctl start/enable, journald logs, restart policy either way. When containerized, the image builds from a Containerfile in the app's own repo; no local registry yet, plausible later if the build step ever needs to move off the host. No reverse proxy sits in front either way.

The pattern doesn't care what the host actually is — a VPS, bare metal, a VM. Only the box underneath, whether that particular app is containerized, and — if it ever stops being Tailscale — how privacy is enforced, can change without this pattern's name or intent changing with it.

This pattern's recommendation follows from two of the bundle's principles: Privacy-first, local-first — a backend I host myself, never a third party's; and Least privilege — rootless Podman over a root-owned Docker daemon, whenever an app is containerized at all.

Architecture

flowchart LR
    subgraph Containerized
        A1[Containerfile in app repo] --> A2[podman build]
        A2 --> A3[Quadlet .container unit]
    end
    subgraph "Bare metal"
        B1[App on host] --> B2[Plain systemd .service unit]
    end
    A3 --> C[systemd]
    B2 --> C
    C --> D[Tailscale Serve]
    D -->|tailnet hostname, no public exposure| E[Any device on my tailnet]