A portable hosting platform, built at home first
My clients run on their own VPSs. At home I run a lab: a single machine that is the MVP of the platform I want everywhere. It's designed so that moving it to bigger hardware or a cloud server is a clone, a bootstrap script and a DNS change.
The shape of it
Everything is Docker Compose. That choice is deliberate: it's boring, portable, readable by anyone, and the same files run on a home box, a 4 € VPS or a dedicated server.
Storage: separate the control plane from the data plane
The machine has a small fast disk and a large slow one. The operating system and a Git repository of configuration live on the fast disk. Everything that grows lives on a dedicated data volume: Docker's data root, the containerd image store, container logs, application data, build artifacts.
- Docker's
data-rootand containerd'srootare moved to the data volume, and both systemd units carryRequiresMountsFor=on it. If the volume isn't mounted, Docker doesn't start, so nothing ever silently fills the system disk. - The daemon is tuned for a long-running host: log rotation (10 MB × 3 per container),
live-restoreso containers keep serving while the Docker daemon itself restarts or upgrades, and a dedicateddefault-address-poolsrange so dozens of tenant networks can never collide with the LAN. - SMART monitoring watches both disks.
On a VPS the same layout maps onto a block-storage volume, which is exactly why the split exists.
The edge: one door, many rooms
Only Traefik publishes ports. It discovers services from Docker labels with exposedByDefault=false, so starting a container never publishes it by accident.
Least privilege for service discovery
Traefik never mounts the Docker socket. It talks to a socket proxy on an internal network that only permits read endpoints (containers, events, networks) and rejects every write. Anything that can read the raw socket effectively has root, so that capability stays in one tiny, read-only container.
Security applied once, everywhere
A middleware chain is attached at the entry-point level, so every route gets it without anyone remembering to add it:
- security headers: HSTS,
nosniff, frame options, referrer and permissions policy, strippedServerandX-Powered-By - per-client-IP rate limiting and response compression
- header-alias stripping, so
X_Real_IPcan't impersonateX-Real-IPfor PHP or CGI backends
Opt-in extras are one label away: a stricter limit for APIs and login routes, a concurrent-request cap, a LAN-only allow-list for admin interfaces, and X-Robots-Tag: noindex for anything that isn't production.
Two certificate strategies
- Platform domain: a single wildcard certificate through the ACME DNS-01 challenge, using the DNS provider's API. Every new subdomain is HTTPS from its first request and costs no extra issuance.
- Client domains: per-host certificates through HTTP-01, once the client's DNS points at the platform.
The first certificate never arrived, and Traefik logged no error. The cause was one innocent label, tls=true, on a router. In Traefik, a router's own TLS block replaces the entry point's TLS defaults rather than merging with them, so that router silently lost both its certificate resolver and its wildcard domain, and Traefik never had a reason to ask Let's Encrypt for anything.
The fix took a minute. The real fix was making it impossible to repeat: the platform's linter now rejects a bare tls=true, and project templates always state their resolver and certificate domain explicitly.
Multi-tenancy without Kubernetes
Everything hosted falls into one of four kinds, and the kind decides the name, the path and the network:
| Kind | ID | Edge network |
|---|---|---|
| Shared tool | openobserve | proxy |
| Personal project | p-portfolio | proxy-personal |
| Client project | c-acme-shop | proxy-c-acme |
| Tool hosted for a client | c-acme-n8n | proxy-c-acme |
Network isolation. Each client has its own edge network, shared only with Traefik. Clients can't reach each other's containers, and databases sit on each stack's private network where nothing from the edge can route to them. Traefik joins new tenant networks live, without a restart.
Environments. A project is one Compose file whose services are its parts (frontend, backend, db, worker). Three small env files run it three ways:
dev
Latest build, LAN-only, noindex, smallest limits.
staging
Client preview, noindex, reduced limits.
prod
Client domain, full limits.
Names are mechanical: c-acme-shop-staging-backend is the container, the Traefik router and the log label. Data, secrets and builds mirror the same path, so anything can be found by reasoning instead of searching. Every container gets CPU and memory limits, no-new-privileges and a pinned image tag. A wrapper makes the environment an explicit argument, so prod is never started by accident:
bin/new-project client acme shop # stack, env files, tenant network
bin/dc clients/acme/shop staging up -d # environment is always explicit
bin/dc clients/acme/shop prod logs -f backend
Secrets never touch Git
Configuration is versioned; secrets are not. They live in a root-owned directory on the host, one file per component and environment, mode 600, and Compose only references them through env_file. The repository could be made public without leaking a single credential.
Observability: one pane for everything
OpenObserve is the single place for logs and metrics, fed by a small collector stack:
- Vector tails every container through its own read-only API proxy (allowed to read logs, nothing else). A VRL transform tags each line with its project and service, parses JSON payloads, and routes Traefik's access log to its own stream.
- That access stream holds every request with host, status, duration and client, which makes "which tenant is throwing 5xx right now" a single query.
- node-exporter metrics go in through Prometheus remote-write.
- The pipeline runs on an internal Docker network with no internet egress, and retention is capped at 30 days so telemetry can't fill the disk.
Policy as code
Conventions only hold if something checks them. A linter renders every stack in every environment and fails on:
- unpinned images, missing CPU or memory limits, missing
no-new-privileges, privileged or host-network containers - published ports anywhere except the edge, and Docker socket mounts anywhere except the read-only proxies
- databases on an edge network, wrong tenant networks, names that break the convention
- TLS and routing mistakes: bare
tls=true, hostnames too deep for the wildcard, references to middlewares that don't exist - invalid Traefik dynamic configuration, and anything in the repository that looks like a secret
It runs as a Git pre-commit hook and again in GitHub Actions on every push. A stack that breaks the rules can't land.
Built to move
Everything needed to rebuild the platform is in one private repository: the stacks, copies of every host-level config (Docker daemon, containerd, systemd overrides, DDNS units, SSH hardening, firewall rules), a bootstrap script for a fresh machine, an inventory of what runs where, and runbooks for disaster recovery and remote access. Moving to new hardware or a VPS is:
git clone …/server-ops && sudo host/scripts/bootstrap-host.sh
# restore data, recreate tenant networks, docker compose up, switch DNS
What's next
- Cloudflare Tunnel in front: no inbound ports at all, the origin IP hidden, DDoS and bot protection at the edge
- a CI image pipeline: GitHub Actions builds and signs versioned images, hosts only pull
- external uptime checks and alerting on error rates, disk usage and certificate expiry
- encrypted off-site backups with scheduled restore tests
It's a lab, but it runs like a small platform: one entry point, isolated tenants, three environments per project, central telemetry and every rule enforced in CI. When it outgrows the box, it moves.