Anass Soulimani Contact
← All writing

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.

PortableEvery host-level setting lives in Git next to a bootstrap script.
Private by defaultOne public entry point; services opt in.
Isolated tenantsEach client gets its own network and resource limits.
ObservableEvery log line and request lands in one place.
Policy as codeRules are checked on every commit, not remembered.

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.

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:

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

Lesson learned

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:

KindIDEdge network
Shared toolopenobserveproxy
Personal projectp-portfolioproxy-personal
Client projectc-acme-shopproxy-c-acme
Tool hosted for a clientc-acme-n8nproxy-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:

Policy as code

Conventions only hold if something checks them. A linter renders every stack in every environment and fails on:

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

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.