A production self-hosting setup for a single VPS: reverse proxy, workflow automation, browser-based remote desktop, and encrypted off-site backups with a tested restore.
Most "self-hosted stack" repos are a docker-compose.yml and a two-line README.
This one documents the parts that actually break: how services reach each other
without exposing ports, how backups get verified, and the traps that cost me days.
It runs a real workload on a 6-core / 8 GB VPS — a workflow engine, an AI gateway, a browser-accessible Linux desktop, and a hosting control panel inside a VM — with exactly two ports open to the internet.
flowchart TB
net(["Internet"])
subgraph host["VPS · Ubuntu 24.04"]
npm["Nginx Proxy Manager<br/><small>the only public container</small>"]
subgraph proxy["docker network: proxy"]
n8n["n8n"]
omni["OmniRoute"]
end
subgraph internal["docker network: interna · internal: true"]
pg[("PostgreSQL")]
redis[("Redis")]
end
kasm["Kasm Workspaces<br/><small>host, port blocked externally</small>"]
subgraph vm["Incus VM · 10.x.x.x"]
panel["Hosting control panel"]
end
end
net -->|"80 / 443 only"| npm
npm --> n8n
npm --> omni
npm --> kasm
npm --> panel
n8n --- pg
omni --- redis
The rule everything follows: no service publishes a port. Containers talk to
each other by name on a shared Docker network. Databases live on a network marked
internal: true — no route to the internet at all, inbound or outbound.
Admin panels (Nginx Proxy Manager, Portainer) bind to 127.0.0.1 only. You reach
them through an SSH tunnel:
ssh -L 8081:127.0.0.1:81 -L 9443:127.0.0.1:9443 mi-vps| Decision | Why |
|---|---|
| Only NPM publishes ports (80/443) | One front door is one thing to audit |
Databases on internal: true |
Compromising the app still gives no path out |
Admin panels bound to 127.0.0.1 |
An open panel is a credential-stuffing target |
| Backups encrypted before leaving the host | The storage provider never sees plaintext |
| SSH keys only, no passwords | Stopped ~800 brute-force attempts per hour |
This is the trap that catches most people. Docker writes its own iptables rules
before UFW's, so ufw deny 5432 does nothing for a published container port.
ports:
- "5432:5432" # publicly reachable, whatever UFW saysThe fix isn't a firewall rule — it's not publishing the port. Every database in this stack is reachable only by container name, from inside its own network.
Requires Docker Engine + Compose plugin on a fresh Ubuntu 24.04 host.
git clone https://github.com/alancorrals95/self-hosted-vps-stack.git
cd self-hosted-vps-stack
cp .env.example .env
$EDITOR .env # fill in domain + generate secrets
chmod 600 .env
# Generate a secret (do this on the server, not your laptop):
tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echo
docker network create proxy # shared network, created once
docker compose --env-file .env -f docker/npm.docker-compose.yml up -d
docker compose --env-file .env -f docker/n8n.docker-compose.yml up -d
docker compose --env-file .env -f docker/omniroute.docker-compose.yml up -d
docker compose --env-file .env -f docker/portainer.docker-compose.yml up -dThen point your DNS A records at the server and add a Proxy Host in NPM for each
service. Certificates are issued and auto-renewed by Let's Encrypt through NPM.
scripts/00-bootstrap-base.sh is meant to run once on a brand-new host: SSH keys
only, UFW, fail2ban, unattended upgrades, swap, and timezone.
Read it before you run it. It disables password authentication — make sure your public key works first, or you will lock yourself out.
Daily, encrypted, off-site, and restore-tested — the last part being the one people skip.
databases + volumes + configs
│
├─► /var/backups/$SERVIDOR 7 days, local, unencrypted
│
└─► rclone crypt ─► Cloudflare R2 encrypted before it leaves the host
rclone crypt encrypts contents locally; the storage provider stores ciphertext it
cannot read. The two encryption keys must also live in a password manager —
without them the off-site copy is an expensive pile of noise.
A truncated database dump weighs almost exactly the same as a complete one. Size tells you nothing. Count the structures instead:
gunzip -c backup.sql.gz | grep -c "CREATE TABLE"
gunzip -c backup.sql.gz | grep -c "COPY "
gunzip -c backup.sql.gz | tail -1 # must say: dump completeIf CREATE TABLE and COPY don't line up, the dump is broken — regardless of how
healthy the file size looks. Full procedure in docs/backups.md.
Two layers, deliberately split by what each can see:
scripts/vigilar-servidor.sh(hourly cron) — sees what the outside can't: disk, memory, dead containers, backups that didn't runn8n/monitoreo-vps.json— checks the services from outside, the way a user would, and watches certificate expiry
Both stay silent when everything is fine. A monitor that emails "all good" every hour becomes noise people filter out, and the one time it matters nobody reads it.
Where a choice had a real alternative, here's what was rejected and why.
btrfs over ZFS for the VM storage pool
ZFS is the better filesystem on paper. But its ARC cache reserves up to half of system RAM by default, and on an 8 GB box RAM is the scarce resource — the exact thing the VM needed. btrfs gives copy-on-write snapshots (sub-second rollback, used six times during setup) without competing for memory.
Incus VM over a container for the hosting panel
The panel wants systemd, its own webserver on 80/443, and kernel-level tuning. Fighting that inside a container is a losing battle. A VM also provides free rollback: snapshot before a risky change, restore in under a second if it breaks.
The bridge lives on 10.100.0.0/24, deliberately far from Docker's 172.17–172.22.
Reverse proxy in front of everything instead of per-service TLS
One place issues and renews certificates, one place holds 80/443, one place to audit. Services never need to know they're behind TLS.
The cost: every new domain needs a Proxy Host entry, or it resolves to nothing. That's an acceptable trade, and it's automatable through NPM's REST API.
Local + off-site instead of off-site only
Local restores are fast and free; off-site protects against losing the host. Keeping 7 days locally means the common case (a bad config an hour ago) never touches the network. Cloud storage is the disaster case, not the daily one.
Each of these cost real debugging time. Details in docs/traps.md.
- OpenSSH honours the first value it reads, not the last. Provider images ship
a
50-cloud-init.confwithPasswordAuthentication yesthat silently overrides your hardening. Name your file so it sorts first —00-hardening.conf. - nginx does not read
/etc/hosts. Use the host-gateway IP for services on the host, container names for containers. 0often means "unlimited", not "none". Two separate products in this stack treat a zero quota as no limit at all. Read the source before trusting a form field.- In netfilter a packet must survive every table. Adding rules in one table won't help if another still has a default DROP.
- Object storage that isn't S3. Cloudflare R2 doesn't implement S3 ACLs; passing
acl = privatemakes every upload fail.
docker/ Compose files, one per stack
scripts/ Bootstrap, backup, encryption, monitoring
n8n/ Importable monitoring workflow
docs/ Architecture, backups, traps
MIT — see LICENSE.