Working at BigTech™ can sometimes skew your view of the world (a surprise to very few of you). One example is seeing how Kubernetes can cope with the massive deployment scale of hundreds/thousands of servers and thinking to yourself that this is effectively required knowledge (and if not now it soon will be) which leads to adopting these technologies for projects that will never see millions of daily active users. I fully leant into k8s for my home lab. At one point I had 6 netboot raspberry pis running a kubernetes cluster with etcd/ceph, using FluxCD and GitOps to manage... what amounts to a couple of self hosted apps. It was a nightmare to maintain. On one hand, I could plug a new raspberry pi into the network, add the mac address to the list of provisioned services and within 10 minutes it would be assimilated into the cluster. On the other hand just idling cause 30% CPU load and the SSDs had an estimated lifetime of about 4 years due to the excessive writes that etcd & ceph would make. All of this complexity was not worth it for a website that had 2-3 users (again this will come as a surprise to very few of you). This was obviously a ridiculous thing to do, but going through this process made me take stock of what I was looking for in a homelab and also how I build and ship everything. Kubernetes was costing me time and money for such little payoff, but the more I thought about it, so was other tech, like Docker. It was time to simplify.
So, the last few projects I've built have been handled differently. I've simply been copying the built artefacts onto a cheap server, no containers, no gitops, no high availability, just ssh and scp. This is a simplification over Docker for several reasons:
-
I don't need to use some kind of registry like Docker or GitHub. The registries are cool and all but they're also time consuming to set up, and create a dependency on a service.
-
Process, hardware, and network isolation sure sound useful but the majority of the time I'm running these services in isolation from each other by other means; physically separated hardware, VMs, or dare I say it, sometimes containers in containers.
-
Speaking of isolation, it's always useful until it's not. I've found if I want to go slightly off-piste the simple becomes exceedingly difficult. Those who've tried to forward GPUs or USB devices know how painful tools like Docker can suddenly be.
-
Having Docker set up on every machine I use is still a pain today. Plus it seems as though the company is increasingly profit seeking - which isn't necessarily innately bad, they have to make money somehow - but the second order effects are net-frustration. I've found "Docker Desktop" increasingly cumbersome and confusing to use as they add more to their product offering, and I don't want a "Docker Core Subscription" nor do I care about privacy policy changes. I just want a reliable way to boot a server.
-
Docker is useful for rebooting services and running multiple services. Docker Compose is the killer feature for me when running small apps. However I've found using the built-in systemd can be quite painless and the syntax is simple. For me, systemd scripts feel like less work than
docker-compose.yml. -
Docker solves half of the problem for commit-to-production services. I use
watchtowerto try and keep containers up to date, but it's a bit of a faff, but then so is sshing into a server to rundocker compose up --force-recreate -build -devery time I want to deploy. I'd prefer a more seamless setup that's fire-and-forget.
I could switch to alternatives, such as lxc or podman, but it only solves some of these problems. So instead I've just been buying more VPS (virtual private servers) and using a combination of ssh, rsync and systemd scripts - all built into most Linux distros. Containers are nice but after a decade of using them I'm not fully convinced they solve problems at the small scale. My belief is that Docker, like k8s, is "trickle-down web scale" (which, much like trickle-down economics, pretends that consolidation at the top benefits the whole but really just makes things harder at the bottom). This old-new way of doing things has truly been delightful. I wanted to share my process, and where I automate bits of it to get 80% of the benefits I saw from Docker, with about 20% of the effort.
This "simple" workflow requires some manual steps because I'm trying to ward off the complexity demons and avoiding stepping into learning new things (though maybe one day I'll finally learn how to use nixos and this whole post will be redundant for me). Given these manual steps, I thought I wrote a playbook on how to set up a new server, and I figured it might be worthwhile sharing, so here it is (with some words around it because this is a blog after all):
Provisioning like its 2005
Instead of using AWS's cloud services (or one of the other competing cloud services), and instead of wedding myself to a framework like Cloudflare Workers or Next.js, my new projects all start out by provisioning a VPS. Before the days of AWS I used to provision a lot of VPSs (vee-pee-ess-es? vee-pee-ess-eye?), and even back then it was incredibly cheap to buy these, but now they're so steeply commoditised you can pick up a VPS for less than €5/$5/£4/mo these days. I use Hetnzer (not an endorsement) but you could try Hostwinds or Bluehost or Inmotion or Hostinger or any other provider and it'll likely be on the order of 1/10th the price of an EC2 instance. When you pick a provider, just ensure you can export your server as an image that you can then take to another provider if you like. The nice thing about administering a VPS is that there's little to no vendor lock-in.
I use Ubuntu Server as my distro of choice. While Ubuntu is reasonably
secure out of the box, there's a few things I do to lock it down a
little further. The downside to a VPS is it does take some
administering, but it's not much. Here's what gets installed and
configured, all of which is either built-in or a single apt install
away:
- WireGuard - VPN tunnel so SSH never touches the public internet
- ufw - firewall to lock down ports (built-in to Ubuntu, just needs enabling)
- Caddy - reverse proxy with automatic HTTPS via Let's Encrypt
- node_exporter - Prometheus metrics for server health (CPU, memory, disk)
- systemd - service management, file watching for auto-deploys (built-in)
- rsync - file transfer for deploying binaries (built-in)
Here's a run-down of the commands I'll run to set up a VPS:
First step, updating and installing packages
This one should be obvious but it's here to remind me that an updated server is a more secure server.
apt update
apt upgrade
Next, install your favourite editor. You might be fine with nano which is built in. I prefer (neo)vim:
apt install -y neovim
cat <<EOF >> ~/.bashrc
export SYSTEMD_EDITOR=nvim
EOF
. ~/.bashrc
Installing wireguard to allow ssh/private access
This will set up Wireguard as a service on the VPS, which allows clients to connect to the server and access all non-firewalled ports and services. This allows us to deny traffic from port 22 publicly, meaning our SSH server will no longer be exposed to the internet, and the only way to login to SSH is to set up Wireguard on a client machine first.
apt install -y wireguard
export WG_PRIVATE_KEY=$(wg genkey | tee /etc/wireguard/private.key)
cat <<EOF > /etc/wireguard/wg0.conf
[Interface]
PrivateKey = $WG_PRIVATE_KEY
Address = 10.10.10.100/32
ListenPort = 51821
PostUp = ufw allow 51821/udp
PostUp = ufw route allow in on wg0 out on eth0
PostUp = iptables -t nat -I POSTROUTING -o eth0 -j MASQUERADE
PostUp = ip6tables -t nat -I POSTROUTING -o eth0 -j MASQUERADE
PreDown = ufw route delete allow in on wg0 out on eth0
PreDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
PreDown = ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
EOF
chmod 0600 /etc/wireguard/*
cat <<EOF >> /etc/sysctl.conf
net.ipv4.ip_forward=1
EOF
sysctl -p
systemctl enable --now wg-quick@wg0.service
With the interface up, we can use wg show to confirm it's running and
grab the server's public key:
wg show wg0 public-key
Copy the output of this, we'll need it for the client config.
Adding peers
Each machine that needs to talk to the server over WireGuard (your
laptop, a CI runner, etc.) needs its own key pair and a [Peer] entry
on the server. The nice thing is that wg can add peers without editing
config files. On the client machine, generate a key pair:
wg genkey | tee private.key | wg pubkey > public.key
Then on the server, add the peer using wg set and persist it with
wg-quick save:
wg set wg0 peer $(cat public.key) allowed-ips 10.10.10.101/32
wg-quick save wg0
This writes the [Peer] block back into wg0.conf for you, no manual
editing needed. Each new peer just needs a unique IP on the 10.10.10.x
subnet.
On the client side you'll still need a wg0.conf (unless you're using
the macOS or Windows GUI). It doesn't need the PostUp/PreDown rules
since the client isn't acting as a gateway. It just needs its own
private key and the server as a peer:
[Interface]
PrivateKey = <client-private-key>
Address = 10.10.10.101/32
[Peer]
PublicKey = <server-public-key>
AllowedIPs = 10.10.10.0/24
Endpoint = <server-public-ip>:51821
Replace the angle-bracket placeholders with your actual values. Running
wg-quick up wg0 on the client should bring up the connection, and you
should be able to ssh 10.10.10.100 to log in to the server. Now an
external firewall can deny access to port 22 but keep access to
51821/udp.
We'll also want a peer for CI, so that GitHub Actions (or equivalent) can deploy over the WireGuard tunnel. The process is the same - generate a key pair locally and add the peer on the server:
wg genkey | tee ci-private.key | wg pubkey > ci-public.key
wg set wg0 peer $(cat ci-public.key) allowed-ips 10.10.10.102/32
wg-quick save wg0
Hang onto ci-private.key and the server's public key (from
wg show wg0 public-key), we'll need them as GitHub secrets later in
the deploy step.
This same process works for multiple servers too. If you provision a
second VPS, give it its own WireGuard key pair and add it as a peer on
the first server (and vice versa). Both servers end up on the same
10.10.10.x subnet, meaning they can talk to each other directly over
the tunnel - handy if one server needs to reach a database or API on
another. Your laptop, CI runner, and all your servers form one flat
private network without any of them exposing SSH to the internet.
If you run into issues here, debugging this setup is far beyond the scope of this article. It can be hellish to debug, there's definitely a learning curve, but once you get accustomed to using wireguard, it's reasonably straightforward.
Setting up the firewall
# Get firewall going
systemctl enable ufw --now
ufw enable
ufw default deny incoming
ufw default allow outgoing
ufw allow from 10.10.10.0/24 to any port 22
ufw allow 80
ufw allow 443
ufw route allow proto tcp from any to any port 80
ufw route allow proto tcp from any to any port 443
systemctl restart ufw
The ufw firewall is built-in to Ubuntu but just needs enabling. I
find it much simpler than running iptables commands directly. Note
that port 22 is only allowed from the WireGuard subnet (10.10.10.0/24)
so SSH is not publicly accessible - you must be connected to WireGuard
to reach it. From another machine, run nmap against the server's
public IP to confirm only the expected ports are open:
$ sudo nmap -sS -sU -p 22,80,443,U:51821 <server-public-ip>
PORT STATE SERVICE
22/tcp filtered ssh
80/tcp open http
443/tcp open https
22/udp open|filtered ssh
80/udp closed http
443/udp open|filtered https
51821/udp open|filtered unknown
You should see 80/tcp and 443/tcp open, plus 51821/udp open from the
WireGuard PostUp rules. Everything else - including 22/tcp - should
be filtered.
Next I'll run ssh-keygen on my local machine and copy the contents of the .pub file, which then get pasted into authorized_keys:
# Add ssh pubkey to auth keys
nvim ~/.ssh/authorized_keys
# Stop password auth on SSH
cat <<EOF >> /etc/ssh/sshd_config
PasswordAuthentication no
EOF
systemctl restart ssh.service
With all of this done I can start assembling the basic services.
Setting up Caddy & node exporter
In order to run a web service, it's a good idea to run things through a reverse proxy. I like Caddy as it's dead simple to administer and has some great defaults.
Installing Caddy
Caddy has their own package setup for Debian, which is how I install it. Here's how:
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install caddy
nvim /etc/caddy/Caddyfile
systemctl enable --now caddy-api.service
Configuring the Caddyfile
The Caddyfile will depend a lot on what you're doing with the server, but here's a basic one:
{
email <your-email-here>
debug
servers {
timeouts {
read_body 1m
read_header 1m
write 1m
idle 10m
}
metrics
}
}
<your-domain-here>.com {
basic_auth /metrics {
<basic-user> <basic-password>
}
reverse_proxy :8100
}
node.<your-domain-here>.com {
basic_auth {
<basic-user> <basic-password>
}
reverse_proxy :9100
}
Walking through this top to bottom:
The global options block (the first { }) sets up a few things. The
email directive tells Caddy to provision TLS certificates
automatically via Let's Encrypt - just having this line means HTTPS
works out of the box with zero extra config. The debug directive turns
on verbose logging which is handy while getting things going (you can
remove it later). The servers block sets some request timeouts to
guard against slow-loris style attacks, and metrics enables a
Prometheus metrics endpoint on Caddy itself.
The first site block (<your-domain-here>.com) is where the application
lives. reverse_proxy :8100 forwards all traffic to whatever is
listening on port 8100 - that'll be our app, set up later. The
basic_auth /metrics block puts a username/password gate in front of
the /metrics path so Prometheus can scrape it but random visitors
can't.
The second site block (node.<your-domain-here>.com) proxies to port
9100, which is where node_exporter runs. This gives
us CPU, memory, disk, and network metrics about the server itself. The
whole subdomain is behind basic_auth since all of its data is
sensitive. I have Prometheus and Grafana set up on a separate machine,
which is why these /metrics endpoints are exposed over the web rather
than only on localhost.
For both site blocks, <basic-user> needs replacing with a username,
and <basic-password> with the output of caddy hash-password. The
same credentials can be given to Prometheus to scrape both endpoints.
Installing node_exporter
Instructions on how to set up Prometheus/Grafana are out of scope for
this guide, but easy enough to find on the web. Here's how
node_exporter is set up:
# Set up node_exporter for monitoring server with Prometheus
cd
wget https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-arm64.tar.gz
tar zxvf node_exporter-1.8.2.linux-arm64.tar.gz
mkdir /opt/node_exporter/
mv node_exporter-1.8.2.linux-arm64/node_exporter /opt/node_exporter/node_exporter
chmod +x /opt/node_exporter/node_exporter
sudo useradd -m node_exporter
sudo usermod -a -G node_exporter node_exporter
chown node_exporter:node_exporter /opt/node_exporter/node_exporter
systemctl edit --force --full node_exporter.service
systemctl enable node_exporter.service --now
lsof -i :9100 # confirm it's running on port :9100
This is going to use systemd to keep the server running, so we'll need to make the unit file for that (that's the systemctl edit --force... command):
[Unit]
Description=Node Exporter
After=network.target
[Service]
Type=simple
User=node_exporter
Group=node_exporter
Restart=on-failure
RestartSec=100ms
WorkingDirectory=/opt/node_exporter
ExecStart=/opt/node_exporter/node_exporter
[Install]
WantedBy=multi-user.target
Running your own application
So now comes the bit where we upload our own application servers to this VPS, and host them on port :8100. This is going depend a lot on how you build your applications, for example a NodeJS server will have different requirements to a Golang one. For my purposes, I've been recently building out apps in Rust and this means I can package everything up into a single binary. The process will likely be similar for Go, but you might need to copy directories of applications and install some extra dependencies for a runtime platform like Node or Ruby.
Creating a service user
The process looks a lot similar to how we set up node-exporter. I'll
create a user for the service, and an /opt/<svc> directory for the
code to live within. Replace all references of <svc> with the name of
your service:
useradd -m <svc>
usermod -a -G <svc> <svc>
mkdir /opt/<svc>
chown <svc>:<svc> /opt/<svc>
touch /opt/<svc>/.env # My apps will read a .env file, so this is a common step for me
chmod 0600 /opt/<svc>/.env
With a user created for the service, I'll also generate an ssh key on the server, that can be used to log in with that user:
mkdir -p /home/<svc>/.ssh
cd /home/<svc>/.ssh
ssh-keygen -f key
mv key.pub authorized_keys
chmod 0600 authorized_keys
chown <svc>:<svc> authorized_keys
cat key # copy the contents of the private key somewhere for save keeping
rm key
Service systemd unit
The ssh-key will be used later when uploading deploy binaries. Next I'll get the systemd files ready:
# Build the service files
systemctl edit --force --full <svc>.service
systemctl enable --now <svc>.service
Again this will look very similar to the node-exporter service:
[Unit]
Description=<svc>
After=network.target
[Service]
Type=simple
User=<svc>
Group=<svc>
Restart=on-failure
RestartSec=100ms
WorkingDirectory=/opt/<svc>
ExecStart=/opt/<svc>/<svc>
[Install]
WantedBy=multi-user.target
File watcher for auto-deploy
Here's where I'll go a step further. To simplify deployment, I'll set up a watcher service which systemd can use to reload the main service whenever the binary changes. This gives me graceful reloading by simply rsyncing the binary over:
systemctl edit --force --full <svc>-watcher.service
[Unit]
Description=<svc> restarter
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl restart <svc>.service
[Install]
WantedBy=multi-user.target
This command is a "oneshot" command. Running this will just run the ExecStart command and exit. On its own it's quite useless but adding a <svc>-watcher.path systemd file can run this service whenever the path changes:
systemctl edit --force --full <svc>-watcher.path
[Path]
PathModified=/opt/<svc>/<svc>
[Install]
WantedBy=multi-user.target
With these two files combined and enabled, they'll run whenever the binary file changes - e.g. from an upload via ftp, scp, or rsync.
systemctl enable --now <svc>-watcher.{service,path}
This is all the necessary scaffolding to now upload the binary to the server. With WireGuard connected and the ssh key for the user, it should be as simple as compiling your binary and copying it over:
rsync -zp target/release/<svc> <svc>@10.10.10.100:/opt/<svc>/
Every time this is run, the application server should be reloaded, thanks to the file watcher, and you should see your service running, thanks to Caddy.
Deploying
We can make this a little smoother by using GitHub Actions or equivalent to automate commit-to-production. Because SSH is behind WireGuard, the action needs to set up a tunnel first. This needs a few secrets:
WG_PRIVATE_KEY- the CI private key we generated earlierWG_SERVER_PUBKEY- the server's public key (fromwg show wg0 public-key)ARTIFACT_HOST- the server's public IP (the WireGuard endpoint)ARTIFACT_SSH_KEY- the SSH private key for the<svc>user
Here's a GitHub Actions workflow I use for a couple of basic Rust projects:
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: 1.80.1
override: true
- name: Check Out
uses: actions/checkout@v4.1.7
- name: Set up Cache
uses: actions/cache@v4.0.2
with:
path: |
~/.cargo/bin
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Cargo Release Build
run: cargo build --release
- uses: actions/upload-artifact@v3
with:
name: <svc>
path: target/release/<svc>
if-no-files-found: error
compression-level: 0
overwrite: true
- name: Connect to WireGuard
run: |
sudo apt-get install -y wireguard
echo "${{ secrets.WG_PRIVATE_KEY }}" > /tmp/wg-key
chmod 600 /tmp/wg-key
sudo ip link add wg0 type wireguard
sudo wg set wg0 \
private-key /tmp/wg-key \
peer ${{ secrets.WG_SERVER_PUBKEY }} \
endpoint ${{ secrets.ARTIFACT_HOST }}:51821 \
allowed-ips 10.10.10.100/32
sudo ip addr add 10.10.10.102/32 dev wg0
sudo ip link set wg0 up
sudo ip route add 10.10.10.100/32 dev wg0
rm /tmp/wg-key
- name: Upload to server
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.ARTIFACT_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan 10.10.10.100 >> ~/.ssh/known_hosts
rsync -zp target/release/<svc> <svc>@10.10.10.100:/opt/<svc>/
Example
An example of this whole set up can be found at github.com:keithamus/tickrs, the code behind https://tick.rs - a little server I run to save numbers to a database.
Conclusions
That's pretty much it. I've built a handful of services using this method which have been running for months without intervention, and have been updated multiple times over the course of their lives. The initial setup of a server takes about 30 minutes but from then on an update is a git push away.
I'm sure this post will prompt people to tell me that I'm doing it horribly wrong, or tell me the reasons why Docker is actually far superior, and that's okay. If you think I could simplify this further, I'd love to know. There's still a place on the web for using these tools at a certain scale. I'm not sure what the tipping point is for me but I intend to use this for as long as I can, and I'll be sure to update this post when one of my projects suddenly needs more than this can deliver.