Homelab 101: setting up your server
For the past year, I've spent a lot of time setting up a homelab. There are many reasons why I decided to pursue this hobby. I'm a insufferable nerd privacy-minded tinkerer. Secondly, I have a paranoid cautious approach to data preservation, and I want my decades of photos and documents to be immortal. Finally, something something surveillance capitalism, digital sovereignty, etc.
Knowing that I am not that unique, I figured I'd document what services I've set up, and why. There are tons of ways to build a homelab. This document might help somebody in the future, and it'll definitely help me!
Hardware & OS
My homelab is running on a Minisforum U300 that I bought used on Blocket (Sweden's Craigslist). The relevant specs are as follows:
| CPU | AMD Ryzen 3 3300U |
| RAM | 2 x 8GiB SODIMM DDR4 |
| SSD | 256 GB + 2 TB SSD |
| OS | Ubuntu |
This is probably overkill for what I use it for. I don't think I've ever had issues with RAM, for example. It's not a very fast CPU (and I've purposefully slowed it down, as we will see) and some things run pretty slow, but it's rarely been a problem. For a lot of these services, you'll be fine with a reasonably recent Raspberry Pi.
As I mentioned, I've made my homelab slower than it has to be. The problem with small computers is that they get hot pretty quickly. Needless to say, small computer + heat = very loud fans. The Minisforum U300 doesn't (to the best of my knowledge) provide a way to cap the fan speed. However, Ubuntu comes with a command that caps the CPU frequency—cpupower. Consequently, I've created a script called quiet-cpu.sh that runs at startup via (root) cron:
#!/bin/bash
cpupower frequency-set -u 1.4GHz
This simple command has been a life saver for my sanity (and relationship).
Infra
Setting up a homelab requires a certain amount of plumbing. The easiest solution is to not expose any of your services to the internet. This way, you don't need to worry too much about security. However, you also won't be able to use any of your services outside your LAN.
I've opted for a hybrid approach. Most services are not exposed to the internet. These services can be reached through a Wireguard VPN tunnel. A smaller subset of my services are exposed to the web. I've deployed a series of protections to shrink the attack surface of these web-facing services. Here, I'll describe my setup and touch upon an important subset of my hardening techniques.
Rootless Docker
My homelab is (almost) entirely dockerized. This setup has many benefits. First, testing a new service is as easy as downloading a yaml file and running docker compose up. There are so many cool self-hosted projects to play around with. There are also a lot of really immature projects that are not worth your time. Being able to docker compose down these services is equally handy.
Docker containers are sandboxed, but the Docker daemon itself runs as root by default. If there are vulnerabilities in the daemon (and who are we kidding) then a clever hacker might be able to get root access to my machine through my services. Is this likely? No. But it is scary and I don't like it. So, I run Docker in rootless mode. Doing so lowers the impact of an adversary gaining control of your daemon or container, since they will be running as a normal user. Docker provides a nice guide for how to set up rootless Docker.
If you decide to also run your daemon rootless, I suggest you do so as early as possible. Unless you're a Docker expert. Then, I guess you can do what you want. As a fellow mortal, I can tell you that migrating a convoluted rootful Docker setup is a nightmare. Rootless networking is a bit quirky, and you will have problems with permissions causing containers to behave erratically. You have been warned.
Later, we'll see how you can use Nginx and Cloudflare to make your services available off-site, without exposing your IP directly to the internet. You will, however, need to make sure your proxied containers are on the same network as Nginx. Otherwise, Nginx will have trouble routing the traffic correctly. Adding this configuration is easy to do:
networks:
web-proxy:
external: true
Make a habit of including this configuration whenever you want to expose a service. Another network-related issue I've encountered is that some containers have quirky DNS configurations when Docker is rootless. This will cause annoying behaviors whenever a container tries to reach the internet. This issue goes away if you configure DNS explicitly:
dns:
- 8.8.8.8 # Or perhaps your pi-hole?
Wireguard
Some services are not worth exposing to the internet. However, you still want to be able to reach them. Perhaps more importantly, you need remote access to your homelab when you've inevitably misconfigured something and your setup breaks just as you excitedly decided to demo your homelab to your mildly interested friends. There are many reasons to configure a VPN to your local environment. I use Wireguard.
Specifically, I use wg-easy. As the name implies, this service provides a very simple interface for creating and managing Wireguard configurations. It works really well. However, you need to run wg-easy in a rootful Docker container. Since Wireguard deals with low-level network configurations, the container needs a lot of permissions. In my opinion, running wg-easy in a rootful container is an acceptable risk. Wireguard is very well-maintained, secure, and widely used.
I strongly suggest you (a) also set up a Pihole and (b) configure Wireguard to use the Pihole as its DNS server. You can do this by configuring
wg-easyor by editing the config file that you download from there. Then, you can add a Local DNS record for your domains via the Pi-hole web UI so that you can reach the services using custom.homedomains.
Honestly, there's not much more to say about Wireguard. I would only add this: do not expose the web UI to the internet. Wireguard is only as secure as its keys.
Nginx
Now, you could stop here and just access your services over Wireguard. This is the safe thing to do. But it's not the fun thing to do. Also, some services are far more convenient if you (at least partially) expose them on the internet. Luckily, there's a Docker image for that: nginx.
The image contains a convenient way to run and configure the Nginx web server. You add new services using template files. These are read and automatically parsed when you start the server. Ideally, you'll have one per service (or domain). For example, this is my Scriberr configuration:
server {
listen 80;
listen [::]:80;
server_name scriberr.home;
access_log /var/log/nginx/scriberr.access.log fail2ban_combined;
error_log /var/log/nginx/scriberr.error.log warn;
location / {
proxy_pass http://scriberr:8089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
allow all;
}
}
This configuration listens for HTTP requests. If they are requesting scriberr.home, then Nginx will route the request to Scriberr. Note that this service is not being exposed to the internet. Rather, I rely on Pi-hole to resolve scriberr.home to my host machine for devices on my LAN and Wireguard.
However, you also need to make sure your containers can talk to each other without tripping over Docker's internal routing. This is where the web-proxy network comes in. To fix tricky NAT "hairpinning" issues—where a container tries to reach another container using its domain name and the traffic gets dropped—you should give your nginx container an internal alias for every domain it serves. For our Scriberr example, you'd include this in nginx's docker-compose.yaml:
networks:
web-proxy:
aliases:
- scriberr.home
Nginx, naturally, runs on port 80, which is privileged by default. If you are running your Docker daemon in rootless mode, then your web server will not be able to bind to that port. You need to explicitly give Docker this power. There are different ways of achieving this—my approach is to run setcap cap_net_bind_service=ep /usr/bin/rootlesskit on startup. It needs to be run periodically, because updating the Docker daemon might reset the permissions.
As a final note, I'd like to point out that you will want to harden services that you expose to the web. There are many ways to do this. At the very minimum, you should configure your Nginx templates to only allow necessary traffic. E.g., you have a Dawarich server and you want to be able to send location data to it when you're out and about. Well, you don't need to expose the full service. The only thing that you need to reach is the endpoint /api/v1/owntracks/points. So, only expose that endpoint! Also, look into fail2ban if you're really paranoid security-minded and want to feel even safer.
Cloudflare
As I mentioned earlier, I route my services via a Cloudflare tunnel using cloudflared. This way, I don't need to open ports on my home network, and I also don't need to expose my IP address. This is not just security by obscurity—Cloudflare handles DDoS protection, caching, and even HTTPS. You can also configure OAuth via Cloudflare (e.g., to harden your Immich server).
Unless you love wading through constantly-changing documentation, configuring Cloudflare will be tedious. However, once you've got it up and running, making changes is much easier. As a matter of fact, I mostly update my setup using Terraform. Rather than write an extensive guide that will be outdated before I hit publish, I'll leave this part to you.
Besides: learning to do these things is part of the fun of self-hosting! :)
Now what?
The astute reader will have noticed that we still haven't installed a single fun service. Correct! In future posts, I will (inshallah) describe some of the neat self-hosted solutions I've found that make my life easier (or at least more fun). Still, I would suggest you have a look at some of these services:
- Immich: replacement for Google Photos
- Dawarich: for gathering a location timeline
- Wallabag: for saving articles (and exporting them to your e-reader)
- Calibre-Web-Automated: for managing and syncing your ebooks
- Ntfy: for sending notifications from your homelab to your cellphone (e.g., when something crashes)
- Pihole: blocking ads and telemetry
Written on June 24, 2026.