Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Self-Hosted VPS Stack

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.

English · Español

CI shell docker license


What this is

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.

Architecture

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
Loading

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

Security model

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

Docker bypasses your firewall

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 says

The 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.

Quick start

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 -d

Then 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.

Hardening a fresh server

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.

Backups

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.

Verify by content, never by size

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 complete

If 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.

Monitoring

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 run
  • n8n/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.

Design decisions

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.

Traps worth knowing

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.conf with PasswordAuthentication yes that 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.
  • 0 often 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 = private makes every upload fail.

Repository layout

docker/     Compose files, one per stack
scripts/    Bootstrap, backup, encryption, monitoring
n8n/        Importable monitoring workflow
docs/       Architecture, backups, traps

License

MIT — see LICENSE.

About

Production self-hosting stack for a single VPS: reverse proxy, workflow automation, remote desktop, and encrypted off-site backups with a tested restore.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages