
A small server stays manageable when every box and every app follows the same handful of rules, decided once. You pick one reverse proxy, one way to run apps, one folder layout, one way to expose things, one deploy method and one backup routine. After that, adding the fifth app costs the same as adding the first, and the server never turns into a zoo of one-off setups.
I self-host apps on small cloud servers this way. The setup suits a dashboard, an automation tool, a read-later app or a small API, and it runs on a server from a provider like Hetzner Cloud that costs a few euros a month. Cheap boxes multiply, so set the rules before the second one. For a cost comparison of hosting options, see Workers vs Hetzner vs DigitalOcean. If you’re coming from managed hosting, hosting beyond Kinsta covers the alternatives.
Name Servers by Role and Label Everything
Name each server by what it does, with a short prefix: acme-tools, acme-qa, acme-api. A name like that tells you what’s on the box before you log in.
In the provider’s console, label every server with its owner, project and role. Labels are what you filter by when the list grows, and what you read when you’re deciding whether something can be deleted.
Keep one documentation page per server that says what runs on it, how to reach it and how it’s backed up. Rename the server the day its job changes, so the name never describes the old job.
Use Caddy as the Reverse Proxy
A reverse proxy is the one program that listens on the public web ports, 80 and 443, and passes each request to the right app by domain name. It works like the front desk of an office building: visitors talk to the desk, and the desk knows which office they want.
I use Caddy. It gets and renews HTTPS certificates on its own, and a working config is a few lines. That takes certificate renewal jobs and expiry reminders off your list.
A WordPress box keeps nginx with PHP-FPM, a well-documented setup that WordPress hosts have run for years. Everything else goes behind Caddy.
Run Apps With Docker Compose
Docker packages an app and everything it needs into a container, so it runs the same way on any server. Docker Compose describes a group of containers in one file and starts or stops them together. If Docker is new to you, Docker for WordPress developers covers the basics.
Give each app one folder under /opt/<app>/ holding a compose.yaml. When you want to know how an app runs, you open that one file.
Anything with a database, a search index or a headless browser belongs in Compose, because those pieces come up and go down together. Starting the app starts its database, and stopping it stops both. Use systemd directly only for host-level jobs, such as a backup script on a timer, or when no container image exists for what you want to run.
Keep Secrets and Data Out of the Repo
Secrets live in root-only env files in /etc/<app>/. They never go in the repo and never in the compose file, because a compose file gets committed, copied and pasted into chats. An env file with permissions 600, owned by root, keeps the passwords in one place that git never sees.
Data lives on named volumes. For anything you’d hate to lose, use a separate attached volume from your provider. You can detach it from one server and attach it to another if the server itself needs replacing.
Bind Ports to Localhost and Close the Firewall
A firewall is the list of which doors on the server are open to the internet. Yours allows 22 for SSH, 80 and 443 for the web, and nothing else.
Every app port binds to 127.0.0.1, which means the app accepts connections from the server itself and from nowhere else. The only ways in from outside are the proxy and a tunnel.
The binding matters most with Docker. A port published as plain 3000:3000 is opened on every network interface, and Docker writes its own firewall rules to do it, which can bypass a host firewall such as ufw. Writing 127.0.0.1:3000:3000 binds the port to the server only.
Pick One of Two Ways to Expose an App
Internal tools, such as dashboards and admin panels, go through a Cloudflare Tunnel with Cloudflare Access in front. A tunnel is an outbound connection from your server to Cloudflare, so Cloudflare carries visitors in and the server opens no ports for those apps. Access adds a login in front of the tool, and only the email addresses you approve get through.
Public apps go through Caddy with no Access in front. That includes anything that mobile apps or API clients call directly, because those clients can’t get past a login wall placed in front of the app. The app handles its own sign-in. Once the accounts you need exist, switch public sign-up off.
The choice comes down to one question per app. If only your team uses it in a browser, it gets the tunnel. If the public or another program calls it, it gets Caddy.
Deploy by Pushing to Main
A push to the main branch builds a container image and stores it in a registry. The server pulls that image by running a small deploy script over SSH, using a key that’s restricted to running only that script. In the server’s authorized_keys file, that looks like this:
command="/opt/app/deploy.sh",restrict ssh-ed25519 AAAA... deploy@ci
If that key ever leaks, all it can do is redeploy. The script itself is a few lines: set the new image tag, run docker compose pull, then docker compose up -d.
Tag each image with the commit it came from. Rolling back is deploying the previous tag. CI/CD: goodbye FTP, hello GitHub Actions walks through the build side if you haven’t set up push-to-deploy before.
Back Up in Layers
The first layer is the provider’s automated whole-server backup, switched on for every box. On Hetzner it costs 20% of the server price and keeps seven daily copies.
Whole-server images are crash-consistent: they capture the disk as if the power had been cut at that moment. That’s fine for databases built to survive power loss. Restoring a single file from an image means building a server from the image first, which is why the second layer exists.
The second layer is a nightly database dump copied to object storage in a different account or provider, such as Cloudflare R2. A dump is a plain file you can restore anywhere, and keeping it with another provider means one locked account can’t take both the server and its backups.
The third layer is a test. Restore the first dump into a throwaway database once, and check that your data is in it. An untested backup is a guess.
Apply the Same Baseline to Every Box
Each server gets the same short list on day one:
- The provider’s firewall, in front of the server, allowing 22, 80 and 443.
- fail2ban watching SSH, which bans addresses after repeated failed logins.
- Key-only logins with password logins off, and root login off wherever a deploy user exists.
- Automatic security updates.
Run through it at creation, and every box starts in a known state. A fixed list also gives you something to compare against when you check a box later. Once every box looks the same, one approach to monitoring works for all of them, and observability when you can’t SSH in covers what to watch.
Leave Working Services Alone
Apply the convention to new servers. Move an old service onto it only when you’re changing that service anyway. A service that runs fine on an older layout costs nothing to leave where it is, and a migration done only for tidiness adds risk without adding anything the service needs.
What This Looks Like on Disk
For an app called app, the layout is:
/opt/app/
compose.yaml
/etc/app/
app.env # chmod 600, owned by root
/etc/caddy/
Caddyfile
The Caddyfile puts the app on the web. The global email block gives Caddy an address for certificate notices, and the site block does the rest:
{
email [email protected]
}
app.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
And the compose.yaml:
services:
app:
image: registry.example.com/acme/app:1.4.2
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file:
- /etc/app/app.env
volumes:
- app-data:/data
volumes:
app-data:
Point the DNS record for app.example.com at the server, run docker compose up -d in /opt/app/, and reload Caddy. Caddy fetches the certificate and the app is live over HTTPS. The next app gets its own /opt/ folder, its own env file in /etc/ and its own block in the Caddyfile, with a different image, port and domain.
The Checklist
Copy this into the documentation page for your first server and tick it off for each new one.
- Naming: role-based name with a short prefix, owner, project and role labels in the console, one doc page, renamed when the job changes.
- Proxy: Caddy in front of every app, nginx with PHP-FPM only on WordPress boxes.
- Apps: Docker Compose in
/opt/<app>/compose.yaml, systemd only for host-level jobs or software with no image. - Secrets and data: root-only env file in
/etc/<app>/, named volumes, an attached volume for data you can’t lose. - Ports: bound to
127.0.0.1, firewall allows 22, 80 and 443 only. - Exposure: internal tools through Cloudflare Tunnel plus Access, public and API-client apps through Caddy, sign-up off once accounts exist.
- Deploys: push to main builds an image, a restricted SSH key runs the deploy script, rollback is the previous tag.
- Backups: provider whole-server backups on, nightly database dump to another provider or account, first dump restored once.
- Baseline: provider firewall, fail2ban, key-only logins, root login off where a deploy user exists, automatic security updates.
- Existing services: left alone until you’re changing them anyway.

Leave a Reply