Skip to content
Back to home

Infrastructure

I rent a VPS, I hardened it, and that's where my applications run. It isn't an implementation detail: it's the difference between writing code and keeping a product alive. Below is how it's put together and why each piece is where it is.

A 2 vCPU, 8 GB RAM VPS running Ubuntu Server LTS. No control panel.

How it's put together

Two separate applications, on separate domains, sharing a single server. The only process listening on the internet is the reverse proxy; everything else talks over internal Docker networks that go nowhere else.

  1. Edge — exposed to the internet

    Caddy

    The server's only open ports are 80, 443 and SSH. It issues and renews TLS certificates for both domains with no intervention.

  2. Applications — shared network

    caudal-web

    gunicorn + Django. No published ports: Caddy reaches it by service name.

    cuadrosjaci-web

    Django REST Framework API. The frontend is static files served by the proxy.

  3. Data — isolated networks, one per app

    caudal-db

    PostgreSQL. Verified it doesn't answer from the proxy's network: only its own application can reach it.

    cuadrosjaci-db

    PostgreSQL, on its own network. A compromised app can't reach the other one's database.

What holds the server up

Access

The only way into the server over the network is a cryptographic key. There's no password to guess.

  • Root SSH login is closed, and so is password authentication.
  • The working account was created with no password at all: there isn't one that could leak.
  • Firewall defaults to deny-all. The only ports open to the internet are SSH, 80 and 443, and all three have a known owner.
  • Unattended security upgrades, enabled and verified.

Backups

Automatic daily backup, and the restore is actually tested instead of assumed.

  • A systemd timer, not cron: if the server was off at the scheduled time, the timer runs the task at boot. Cron simply skips it.
  • Tiered retention: daily, weekly and monthly. It answers a different threat than a dead disk — an import that duplicates everything gets noticed weeks later, and there what saves you is the depth of the history.
  • The verifier restores the dump into a throwaway database and compares row counts against the source. A dump that 'didn't fail' and a backup that works are not the same thing.
  • The verifier deliberately ignores volatile tables, like session storage. A verifier that cries wolf every day ends up ignored, and that's the worst possible outcome.

Conventions

Container and volume names are mandatory, because the server's maintenance discovers what to do from them.

  • An application that follows the convention gets backed up and maintained automatically. One that doesn't goes unbacked-up silently: there's no error, it just never shows up. That's the worst kind of failure, which is why the convention isn't optional.
  • No application or database container publishes ports to the host. The reason isn't aesthetic: Docker writes iptables rules beneath the firewall, so publishing a port leaves the database exposed to the internet even when the firewall says otherwise.
  • Per-service memory and CPU limits. Not to save resources — there's RAM to spare — but so a misbehaving application can't starve the others.
  • Log rotation on every service. Without it Docker's logs grow unbounded and silently fill the disk.

Decisions, and why

  • Bare Ubuntu, no control panel

    Instead ofcPanel, Plesk, or a Docker-native panel

    Traditional panels are built for shared PHP hosting: they manage vhosts, FTP accounts and per-user databases, none of which applies to a container stack. They also compete for ports 80 and 443 and touch the system's network configuration, which breaks Docker's bridge. Docker-native panels were a real option, but they add one more abstraction to debug on top of the application.

  • Close the old access only after testing the new one

    Instead ofapplying all the hardening in one pass

    If the new account can't log in or lacks permissions, and you already closed root login, you're locked out of the server. Verifying in between turns an irreversible mistake into a trivial one.

  • A timer that rolls back SSH changes on its own

    Instead oftrusting the open session as a safety net

    Validating the config's syntax doesn't prove you can still get in: a badly written rule or an odd permission passes validation and locks you out anyway. Before applying the change I schedule its automatic rollback fifteen minutes out, and cancel it only after verifying access in a fresh session. The open session only helps while it stays open; the timer doesn't depend on that.

  • No fail2ban

    Instead ofinstalling it out of habit

    With password authentication disabled, brute force has nothing to hit: its contribution is reduced to cleaning log noise. It's a good tool solving a problem that doesn't exist here. The condition to revisit is written down: the day I expose a service with password login, the decision changes.

  • Moving the database to my own server

    Instead ofstaying on managed Postgres with backups included

    I gained control over the project's most sensitive data and minimal latency: the app and the database sit on the same internal network, without going out to the internet on every query. The trade was explicit and not free: the managed provider had been covering backups, and since the move nobody does. That's why backups stopped being a pending task and became blocking.

  • No Cloudflare in front, for now

    Instead ofproxying traffic for free caching and DDoS protection

    Putting a second proxy in front collides with the app's IP-based blocking, and three things have to be right at once: the blocking library's proxy count, the reverse proxy's trusted ranges, and how the certificate gets issued. If the count is wrong, the system blocks the proxy's IP instead of the attacker's — or locks out legitimate users entirely. Three things to get right simultaneously, on an app that hadn't reached production yet. At the current scale, the gain is marginal.

What isn't solved yet

I publish this on purpose. A server with no known weak points is a server nobody audited.

  • Backups are still local only

    The backups live on the same disk as the data. That covers logical errors — an import that duplicates everything, an accidental deletion — which is the most frequent failure. It does not cover losing the server: if the disk dies, data and backups go together. In the meantime, the managed provider I migrated from stays switched on deliberately: with no copy outside the server, that database became part of the backup design rather than a leftover of the migration.

    When I'll fix it Before switching off the previous provider. That's the exact point where the missing off-site copy goes from acceptable to dangerous.