My homelab path.
The story on how did I land to my current homelab setup.
Among my many hobbies, maintaining my homelab is one that brings me the most satisfaction. A homelab or home server is a computer that, instead of serving as a workstation, is used to provide services locally. The most common use case is NAS (Network Attached Storage), but with a bit of creativity and the right software, it’s entirely possible to set up personal finance tools, media servers, or multiplayer game servers.
In this post, I share the story of the homelab I’ve built over the past 2 years.
The beginning and the tipping point #
I already had most of my tech needs covered two years ago: I either solved them locally on my workstation, or I relied on free services. I also had access to infrastructure that a friend lent me for small projects, so setting up a homelab didn’t make much sense for me at the time.
However, over time I realized that the effort of maintaining my own infrastructure became a better option than tolerating the enshittification of the services I depended on. Seeing my colleagues’ experiences with their homelabs also sparked my curiosity about the topic. The moment I decided to take the reins was when I finished upgrading my personal PC and ended up with extra hardware that I could put to good use for a proof of concept.
I dedicated some time to researching the state of the art. Fortunately, the spaces I explored were full of people willing to share their experiences and setups. I watched videos on different ways to build the infrastructure, checked out various repositories, and studied the modern tools used for this purpose, both commercial and open source.
My first setup #
I acquired most of the components for this computer back in 2013. Since I knew this would be a temporary setup, I only invested in persistent storage, which I could recycle if I ever built a permanent one.
For this proof of concept, I decided to use Proxmox as the operating system. It was one of the most recommended options for managing containers and virtual machines, it had a nice web interface, and had plenty of online guides available in case I wanted to customize anything. I also wanted to get familiar with LXC as a system containerization technology.
I decided to work with containers using Fedora images as a base and provision the open-source applications I’d use directly into them. One downside of this approach is that many applications distributed for local hosting come packaged as Docker images meant to be used with Docker Compose. In those cases, it would require the extra work of adapting or repackaging them to install them directly inside the container. At worst, I could always nest containers, but that was something I wanted to avoid.
Instead of manually installing the applications into the containers, I chose to use Ansible as my provisioning tool, since I was already familiar with it from my day job. This decision might seem overkill for the project’s initial scale, but shifting the mindset from “maintaining infrastructure” to “building a system” is something that feels much more natural to me as a developer. Plus, it keeps a record of the system’s composition, which is handy for someone who tends to forget things and doesn’t have the patience to document everything manually. I also evaluated other provisioning tools (Kubernetes, OpenTofu, things like that), but they didn’t make sense given that my project wouldn’t scale beyond local use.
My initial plan was to migrate the Discord bot and the games I was hosting with my friend, and then build upon that foundation.
flowchart LR
subgraph Proxmox["Proxmox VE Host"]
subgraph LXC0["LXC Container: dns"]
Cockpit0["Cockpit Web UI"]
DNS["<b>DNS</b><br/>BIND9<br/>port 53"]
end
subgraph LXC1["LXC Container: maruchan"]
Cockpit1["Cockpit Web UI"]
Maru["<b>Maruchan Bot</b><br/>Podman container"]
end
subgraph LXC2["LXC Container: ai"]
Cockpit2["Cockpit Web UI"]
AI["<b>AI Service</b><br/>ramalama serve<br/>port 8080"]
end
end
Discord["<b>Discord Bot</b><br/>External"]
LAN["<b>Local LAN</b>"]
Discord <--> Maru
LAN <--> DNS
LAN <--> AI
AI --> Maru
DNS -->|"DNS resolution"| LAN
DNS -->|"DNS resolution"| LXC1
DNS -->|"DNS resolution"| LXC2
Homelab first steps - 2024-11-28.
With this foundation, the architecture was simple, but I already had a clear idea of how to organize things to ensure scalability. As a starting point, I decided to maintain two repositories: one for roles and playbooks, and another for the inventory. The main playbook organizes three tasks:
- Provision basic services on the server:
- Proxmox
- Cockpit
- Certificates
- Shared storage
- Spin up containers:
- LXC containers with Fedora images
- Configure IPs and base applications (SSH, Cockpit, firewall, etc.)
- Provision the containers:
- Setup roles
Proof of concept #
Since this was still a personal project at this stage, I didn’t want to be too ambitious about what I added to the server. Three months later, the applications I ended up adding were a Forgejo instance and some games to run locally.
flowchart LR
subgraph Proxmox["Proxmox VE Host"]
subgraph LXC0["LXC Container: dns"]
DNS["<b>DNS</b><br/>BIND9<br/>port 53"]
end
subgraph LXC1["LXC Container: traefik"]
Traefik["<b>Traefik</b><br/>Reverse Proxy<br/>ports 80, 443<br/>Let's Encrypt / Cloudflare DNS-Challenge"]
end
subgraph LXC2["LXC Container: forgejo"]
Forgejo["<b>Forgejo</b><br/>Git Server<br/>port 3000 / 22"]
end
subgraph LXC3["LXC Container: semaphore"]
Semaphore["<b>Semaphore</b><br/>Ansible CI/CD"]
end
subgraph LXC4["LXC Container: ai"]
AI["<b>AI Service</b><br/>ramalama serve<br/>port 8080<br/>llama3.2:3b"]
end
subgraph LXC5["LXC Container: maruchan"]
Maru["<b>Maruchan Bot</b><br/>Discord Bot<br/>Podman container"]
end
subgraph LXC6["LXC Container: luanti"]
Luanti["<b>Luanti</b><br/>Minetest Server<br/>port 30000"]
end
subgraph LXC7["LXC Container: veloren"]
Veloren["<b>Veloren</b><br/>Game Server<br/>ports 14004, 14005, 14006/udp"]
end
subgraph LXC8["LXC Container: starbound"]
Starbound["<b>Starbound</b><br/>Game Server<br/>port 21025<br/>RCON port 21026<br/>SteamCMD"]
end
end
Discord["<b>Discord Bot</b><br/>External"]
LAN["<b>Local LAN</b>"]
Discord <--> Maru
LAN <--> Traefik
LAN <--> Luanti
LAN <--> Veloren
LAN <--> Starbound
LAN --> DNS
DNS -->|"DNS resolution"| LXC1
DNS -->|"DNS resolution"| LXC2
DNS -->|"DNS resolution"| LXC3
DNS -->|"DNS resolution"| LXC4
Traefik -->|"HTTPS routing"| Forgejo
Traefik -->|"HTTPS routing"| Semaphore
AI -->|"http://localhost:8080"| Maru
Maru -->|"RCON port 21026"| Starbound
Final state of the POC - 2025-01-27.
One improvement I made was structuring the LXC container generation process so that the system creates them directly from the inventory. For each container, I created variable files in inventory/hosts_vars/, where I defined the base configuration, DNS records, IPs, external storage space, and resource limits according to their intended roles. Then, in inventory/main.yml, I defined which hosts corresponded to containers and which roles would be assigned to each one. I wanted to replicate the experience of using a docker-compose.yml file, but adapted to the type of infrastructure I was building.
Example: Container definition - Voxelibre
inventory/hosts_vars/voxelibre.yml
ansible_host: <IP>
hostname: voxelibre
# Container
proxmox_container:
vmid: 1013
disk: 'local-lvm:8'
netif:
- "ip=<IP 2>/24,bridge=vmbr2"
cores: 2
memory: 4096
unprivileged: True
# External storage
storage_mnt:
- path: /voxelibre
target: /var/lib/minetest/voxelibre
uid: 996
gid: 996
size: 20G
# Luanti Mode
luanti_config:
game:
name: mineclone2
url: https://content.luanti.org/uploads/bf06e78d80.zip
user_password: p0t4t0p0t4t0
mods:
- name: mapserver
url: https://content.luanti.org/uploads/8593934e95.zip
- name: whitelist
url: https://content.luanti.org/uploads/3856ecc059.zip
- name: xban2
url: https://content.luanti.org/uploads/14be84e5ee.zip
- name: illumination
url: https://content.luanti.org/uploads/65d28de215.zip
# whitelist deps
- name: lib_chatcmdbuilder
url: https://content.luanti.org/uploads/e34ab089d8.zip
world:
id: "{{ hostname }}"
name: Grillo's Server - VoxeLibre
description: Personal server :)
host: "{{ hostname }}.{{ domain_name }}"
url: https://{{ hostname }}.{{ domain_name }}
admin_name: grillo
announce: false
port: 30000
settings:
- enable_minimap = true
- mapserver.enable_crafting = true
- mapserver.send_interval = 10
- secure.http_mods = mapserver
- mapserver.url = http://127.0.0.1:8080
- enable_rollback_recording = true
mapserver:
port: 8080
# DNS settings
dns_list:
- "{{ hostname }}.luanti IN A <IP>"
# Traefik Config
traefik_config:
http:
routers:
to-voxelibre:
rule: Host(`{{ hostname }}.{{ domain_name }}`)
service: voxelibre
services:
voxelibre:
loadBalancer:
servers:
- url: http://<IP 2>:8080inventory/main.yml
containers:
hosts:
voxelibre:
role_luanti:
hosts:
voxelibre:On the infrastructure side, I added two more tools: Traefik and SemaphoreUI.
SemaphoreUI is an application for orchestrating automated processes with a comfortable web interface. For my use case, it worked well as a frontend to automate and execute the server playbooks. To enable it, I just needed to provide an SSH key for the host and access to the repositories already hosted on the Forgejo instance.
Traefik, on the other hand, is an HTTP proxy; I mainly used it to expose web services through a single entry point, and that way, avoiding the need to generate certificates and DNS records for each container separately.
As I mentioned before, many of these applications are distributed as pre-generated images and often don’t provide instructions on how to use them directly outside of a container. However, it’s possible to deduce the process by looking at the container files and the CI artifacts. By studying these, I decided to generate my own RPM packages. I started with SemaphoreUI and Traefik, but if you explore the organization where I maintain the packages, you’ll notice I didn’t stop there.
The test result #
A few months later, due to a computer failure in the house, we had to free up the hardware I was using for the homelab. By that point, I already considered the proof of concept a success and was planning to buy a permanent rig.
Defining clear expectations about what I wanted to achieve was something I didn’t want to underestimate, as at this point I saw the potential for it to become something bigger than a personal project. I still wanted to avoid it turning into critical infrastructure, so I would leave out things like password managers or Home Assistant. I also didn’t want to expose public services, so mail servers, websites, or Mastodon instances were off the table (unless they were local experiments). With clear objectives, I looked for a small, energy-efficient machine, but with enough resources to run what I wanted.
A major change I made was migrating the host from Proxmox to Fedora Server. My main reason for leaving Proxmox was its dependency on Debian as a base. This might sound counterintuitive, as it’s generally easier to manage a system on distros with long update cycles like CentOS or Debian, precisely because they avoid incorporating software versions that change how one interacts with the system. From that perspective, Fedora’s update cycle might sound like a disadvantage, but in my case, I like to explore the latest application versions and contributing when possible, so using Fedora helps me get involved in projects without sacrificing too much stability. Another factor that enabled the migration was that the Incus project was already gaining traction and looked like a viable alternative for managing system containers on Fedora.
flowchart LR
subgraph Fedora["Fedora Host (Incus)"]
subgraph LXC_DNS["LXD Container: dns"]
DNS["<b>DNS</b><br/>BIND9<br/>port 53"]
end
subgraph LXC_TRAEFIK["LXD Container: traefik"]
Traefik["<b>Traefik</b><br/>Reverse Proxy<br/>ports 80, 443<br/>Let's Encrypt / CF DNS-Challenge"]
end
subgraph LXC_AUTHELIA["LXD Container: authelia"]
Authelia["<b>Authelia</b><br/>Auth Server<br/>port 9091<br/>OIDC Provider<br/>Access Control"]
end
subgraph LXC_WGPORTAL["LXD Container: wgportal"]
WGPortal["<b>WireGuard Portal</b><br/>VPN Management<br/>web: 8080<br/>VPN: 51820<br/>SQLite"]
end
subgraph LXC_FORGEJO["LXD Container: forgejo"]
Forgejo["<b>Forgejo</b><br/>Git Server<br/>port 3000 / 22<br/>SQLite"]
end
subgraph LXC_SEMAPHORE["LXD Container: semaphore"]
Semaphore["<b>Semaphore</b><br/>Ansible CI/CD"]
end
subgraph LXC_JUPYTER["LXD Container: jupyter"]
Jupyter["<b>JupyterLab</b><br/>port 8888"]
end
subgraph LXC_TRILIUM["LXD Container: trilium"]
Trilium["<b>Trilium Notes</b><br/>Knowledge Base<br/>multi-instance"]
end
subgraph LXC_COMFYUI["LXD Container: comfyui"]
ComfyUI["<b>ComfyUI</b><br/>AI Image Gen<br/>ROCM (PyTorch)"]
end
subgraph LXC_OPENWEBUI["LXD Container: openwebui"]
OpenWebUI["<b>Open WebUI</b><br/>Chat Interface<br/>port 8080"]
end
subgraph LXC_RAMALAMA["LXD Container: ai"]
Ramalama["<b>Ramalama</b><br/>AI Model Serving<br/>port 8080<br/>llama3.2:3b"]
end
subgraph LXC_MARUCHAN["LXD Container: maruchan"]
Maru["<b>Maruchan Bot</b><br/>Discord Bot<br/>Podman"]
end
subgraph LXC_LUANTI["LXD Container: luanti"]
Luanti["<b>Luanti</b><br/>Minetest Server<br/>port 30000"]
end
subgraph LXC_VELOREN["LXD Container: veloren"]
Veloren["<b>Veloren</b><br/>Game Server<br/>14004, 14005, 14006/udp"]
end
subgraph LXC_STARBOUND["LXD Container: starbound"]
Starbound["<b>Starbound</b><br/>Game Server<br/>21025 / 21026<br/>SteamCMD"]
end
subgraph LXC_MANGBAND["LXD Container: mangband"]
Mangband["<b>Mangband</b><br/>MUD Game<br/>port 18346"]
end
end
Discord["<b>Discord Bot</b><br/>External"]
LAN["<b>Local LAN</b>"]
VPN["<b>WireGuard VPN</b>"]
Discord <--> Maru
LAN <--> Traefik
LAN <--> DNS
LAN <--> Forgejo
VPN <--> WGPortal
Traefik -->|"HTTPS + Authelia"| Authelia
Traefik -->|"routes / api"| Forgejo
Traefik -->|"routes / web"| Semaphore
Traefik -->|"routes"| Jupyter
Traefik -->|"routes"| Trilium
DNS -->|"DNS resolution"| LXC_TRAEFIK
DNS -->|"DNS resolution"| LXC_AUTHELIA
DNS -->|"DNS resolution"| LXC_FORGEJO
DNS -->|"DNS resolution"| LXC_SEMAPHORE
DNS -->|"DNS resolution"| LXC_RAMALAMA
DNS -->|"DNS resolution"| LXC_OPENWEBUI
DNS -->|"DNS resolution"| LXC_MARUCHAN
DNS -->|"DNS resolution"| LXC_LUANTI
DNS -->|"DNS resolution"| LXC_VELOREN
DNS -->|"DNS resolution"| LXC_STARBOUND
DNS -->|"DNS resolution"| LXC_MANGBAND
OpenWebUI -->|"API calls"| Ramalama
Maru -->|"AI inference"| Ramalama
LAN <--> OpenWebUI
LAN <--> ComfyUI
LAN <--> Luanti
LAN <--> Veloren
LAN <--> Starbound
LAN <--> Mangband
A couple of months later - 2025-11-05
The decision to use Ansible made the migration much easier. I updated the playbooks to use Incus instead of LXC, and everything worked without issues. Regarding roles and containers, apart from a couple of minor issues with permissions and how resource limits were defined, the transition was fairly clean. Yes, it was a big change and there are still things to migrate, but it was enough to get the services back up.
For applications, this time I involved my partner to look for use cases. Among the services she relied on, Notion looked like a good target to find a local alternative. After trying AppFlowy, Anytype, Obsidian, and others, we ended up on Trilium. It’s not a panacea, but it can be hosted locally and synced with its multiplatform desktop app. It helps a lot that they are open to accept improvements.
On the infrastructure side, I added a VPN with WGPortal and authentication for services using Authelia. Since I still planned to add more users to the system in the future, I added a mechanism to set firewall rules per container, ensuring they wouldn’t expose more than necessary or see network segments they shouldn’t have access to.
Drawing the rest of the owl #
We are approaching the second anniversary of the server soon, and I feel it has grown quite a bit. Despite that, there haven’t been any major structural or infrastructure changes since then. Many of the applications are used daily, while others remain proof of concept.
If I have focused my efforts, it has been on improving security and simplifying account provisioning. Since I added access for some friends and family, I decided to move the user registry to an LDAP instance (lldap). The rest has been just testing different applications to see what sticks.
flowchart LR
subgraph Fedora["Fedora Host (Incus)"]
subgraph LXC_DNS["LXD Container: dns"]
DNS["<b>DNS</b><br/>BIND9<br/>port 53"]
end
subgraph LXC_TRAEFIK["LXD Container: traefik"]
Traefik["<b>Traefik</b><br/>Reverse Proxy<br/>ports 80, 443<br/>Let's Encrypt / CF DNS-Challenge"]
end
subgraph LXC_AUTHELIA["LXD Container: authelia"]
Authelia["<b>Authelia</b><br/>Auth / OIDC<br/>port 9091"]
end
subgraph LXC_LLDAP["LXD Container: lldap"]
LLDAP["<b>LLDAP</b><br/>LDAP Server<br/>PostgreSQL :5432"]
end
subgraph LXC_WGPORTAL["LXD Container: wgportal"]
WGPortal["<b>WireGuard Portal</b><br/>VPN Mgmt<br/>web: 8080, VPN: 51820"]
end
subgraph LXC_SEMAPHORE["LXD Container: semaphore"]
Semaphore["<b>Semaphore</b><br/>Ansible CI/CD"]
end
subgraph LXC_MAILPIT["LXD Container: mailpit"]
Mailpit["<b>Mailpit</b><br/>Mail Testing"]
end
subgraph LXC_FORGEJO["LXD Container: forgejo"]
Forgejo["<b>Forgejo</b><br/>Git Server<br/>port 3000 / 22<br/>SQLite"]
end
subgraph LXC_FORGEJO_RUNNER["LXD Container: forgejo_runner"]
ForgejoRunner["<b>Forgejo Runner</b><br/>CI/CD Runner<br/>Podman backend"]
end
subgraph LXC_GIT_PAGES["LXD Container: git_pages"]
GitPages["<b>Git Pages</b><br/>Static Hosting"]
end
subgraph LXC_TRILIUM["LXD Container: trilium"]
Trilium["<b>Trilium Notes</b><br/>Knowledge Base<br/>multi-instance"]
end
subgraph LXC_JELLYFIN["LXD Container: jellyfin"]
Jellyfin["<b>Jellyfin</b><br/>Media Server"]
end
subgraph LXC_ACTUAL["LXD Container: actualbudget"]
ActualBudget["<b>Actual Budget</b><br/>Budgeting App"]
end
subgraph LXC_FILEBROWSER["LXD Container: filebrowser"]
FileBrowser["<b>File Browser</b><br/>Web File Mgmt<br/>media: /opt/media"]
end
subgraph LXC_ROMM["LXD Container: romm"]
ROMM["<b>ROMM</b><br/>ROM Manager<br/>PostgreSQL :5432<br/>Valkey, Coturn"]
end
subgraph LXC_RAMALAMA["LXD Container: ai"]
Ramalama["<b>Ramalama</b><br/>AI Model Serving<br/>port 8080<br/>llama3.2:3b"]
end
subgraph LXC_OPENWEBUI["LXD Container: openwebui"]
OpenWebUI["<b>Open WebUI</b><br/>Chat Interface<br/>port 8080"]
end
subgraph LXC_PIWEB["LXD Container: piweb"]
PiWeb["<b>Pi-Web</b><br/>Agent / Sessiond"]
end
subgraph LXC_MARUCHAN["LXD Container: maruchan"]
Maru["<b>Maruchan Bot</b><br/>Discord Bot<br/>Podman"]
end
subgraph LXC_TUWUNEL["LXD Container: tuwunel"]
Tuwunel["<b>Tuwunel</b><br/>Local Matrix Server<br/>port 8008<br/>Nginx"]
end
subgraph LXC_LIVEKIT["LXD Container: livekit"]
LiveKit["<b>LiveKit</b><br/>WebRTC Video<br/>Valkey, Nginx"]
end
subgraph LXC_CINNY["LXD Container: cinny"]
Cinny["<b>Cinny</b><br/>Matrix Client<br/>Nginx"]
end
subgraph LXC_OWNCast["LXD Container: owncast"]
Owncast["<b>Owncast</b><br/>Live Streaming<br/>FFmpeg"]
end
subgraph LXC_BFCOMPANION["LXD Container: bitfocus_companion"]
BFCompanion["<b>Bitfocus Companion</b><br/>Broadcasting"]
end
subgraph LXC_LUANTI["LXD Container: luanti"]
Luanti["<b>Luanti</b><br/>Minetest<br/>port 30000"]
end
subgraph LXC_MANGBAND["LXD Container: mangband"]
Mangband["<b>Mangband</b><br/>MUD RPG<br/>port 18346"]
end
end
Discord["<b>Discord</b><br/>External"]
LAN["<b>Local LAN</b>"]
VPN["<b>WireGuard</b><br/>VPN"]
Discord <--> Maru
VPN <--> WGPortal
LAN <-->|osc| BFCompanion
LAN <-->|ssh:repo| Forgejo
LAN <-->|matrix| Tuwunel
LAN <-->|matrix:videocalls| LiveKit
LAN <-->|ldap| LLDAP
LAN <--> Luanti
LAN <--> Mangband
LAN <--> Traefik
LAN <--> DNS
DNS -->|"DNS resolution"| LXC_BFCOMPANION
DNS -->|"DNS resolution"| LXC_FORGEJO
DNS -->|"DNS resolution"| LXC_TUWUNEL
DNS -->|"DNS resolution"| LXC_LIVEKIT
DNS -->|"DNS resolution"| LXC_LLDAP
DNS -->|"DNS resolution"| LXC_LUANTI
DNS -->|"DNS resolution"| LXC_MANGBAND
DNS -->|"DNS resolution"| LXC_TRAEFIK
DNS -->|"DNS resolution"| LXC_WGPORTAL
Traefik -->|"HTTPS + Authelia"| Authelia
Traefik -->|"routes"| PiWeb
Traefik -->|"routes"| Ramalama
Traefik -->|"routes"| ActualBudget
Traefik -->|"routes"| Cinny
Traefik -->|"routes"| BFCompanion
Traefik -->|"routes"| Forgejo
Traefik -->|"routes"| Jellyfin
Traefik -->|"routes"| LLDAP
Traefik -->|"routes"| Mailpit
Traefik -->|"routes"| FileBrowser
Traefik -->|"routes"| Trilium
Traefik -->|"routes"| Owncast
Traefik -->|"routes"| GitPages
Traefik -->|"routes"| Ramalama
Traefik -->|"routes"| ROMM
Traefik -->|"routes"| Semaphore
Traefik -->|"routes"| WGPortal
LLDAP -->|"auth"| Authelia
ForgejoRunner -->|"API"| Forgejo
GitPages -->|"API"| Forgejo
Forgejo -->|"Infra Repo"| Semaphore
OpenWebUI -->|"AI API"| Ramalama
PiWeb -->|"AI API"| Ramalama
Maru -->|"AI inference"| Ramalama
FileBrowser -->|"/opt/media"| Jellyfin
Current homelab state - 2026-10-15
To infinity and beyond! #
The next steps will involve moving some of the services still in the experimental phase to a secondary machine. I also plan to continue looking for mechanisms to handle backups and snapshots; I haven’t found anything that fully convinces me yet, especially because I hope to find something that offers a better experience than rsync and Btrfs’ manual snapshot system. Evaluating other applications like immich or freshrss is also on the list.
Additionally, I’ll continue maintaining the packages for the applications I use. I believe many of them could be incorporated into Fedora, but I haven’t done the exhaustive study required to determine if it’s viable. For now, the builds are hosted on my COPR account, but if anyone is up for fixing them, I’d be happy to help with the review process.
I plan to write more entries in this blog in the future to discuss the other applications I’ve tested in more detail, how to install them on Fedora, and take the opportunity to improve the documentation of the roles in the repository, which is currently falling short.
Comments
Join the conversation