i-ziad

9 min read

No spare hardware? Choosing a homelab setup that keeps your PC safe

Recently, I went down the rabbit hole of setting up my own homelab. I wanted to test if self-hosted open source apps can replace the paid apps I use. I also wanted a simple server for my own prototypes, instead of renting a VPS. Having no spare hardware to run this experiment on, I decided to host my homelab on my own PC (an old but reliable Lenovo laptop that is valiantly fighting its planned obsolescence).

Since I was going to run this experiment on my own PC, my setup had to meet four criteria:

  1. Keep my PC usable: no reinstall, no dual boot, and resource limits so the server cannot starve my day-to-day work.
  2. Security first: least privilege, and no root daemon that a compromised app can abuse.
  3. Complete isolation: untrusted apps or prompt-injected AI agents must not reach my personal files.
  4. Easy setup from the CLI: no web UI needed, and commands that I can later turn into code.

With this security-first frugal mindset, I went through the most popular homelab setups on Reddit's r/HomeServer and r/homelab communities.

I left out the setups that take over a whole machine, since I wanted to keep using my Ubuntu OS for day-to-day tasks: Proxmox VE (a virtualization platform with a web UI), TrueNAS Community Edition (a storage-first NAS system), YunoHost (a self-hosting system with a curated app catalog) and IncusOS (an immutable OS dedicated to running Incus). They are great choices if you have a spare machine.

The remaining setups can be sorted into three groups by the isolation they provide: application containers, system containers and virtual machines.

1. Application containers

This is by far the simplest solution in terms of setup but it also provides the least isolation out of the three. With this setup, an app is packaged with its runtime (e.g., Python, Node.js), system tools and libraries (e.g., a shell, glibc, OpenSSL and CA certificates) into a container image. From that image, you start containers that run as isolated processes on the host's kernel.

There are many available application container solutions with Docker and Podman being the most popular. Additionally, the Open Container Initiative (OCI) standardizes how container images are defined, how they are shared and how they are run, facilitating the interchangeability of these containerization solutions. One common feature of all these containerization solutions is that they were designed with a lightweight-portability first mindset. Sharing the host's kernel allows them to start fast and use few resources on the host machine.

Sharing the host's kernel opens a large attack surface for a compromised application or poorly configured image. In the worst case scenario, a compromised application might give the intruder root access on the host machine, which means full control of my PC and every personal file on it. This risk is real with Docker's default setup: its daemon runs as root and does not remap user IDs, so root in a container is root on the host. Podman has no daemon and runs containers as my own user, so an escape lands as me, with access to all my personal files.

Docker and Podman best practices (Docker, Podman) reduce what a compromised application can do with proper configuration. The problem, however, is that not all applications follow these best practices. I could review the Compose files of these apps and try to fix any security misconfigurations, but I'd rather test apps in a contained environment before committing to fixing their configurations.

2. System containers

System containers take a different approach. An application container packages the bare minimum to run one app. A system container runs a full Linux OS on the host's kernel, where you can run many apps, or even application containers, side by side.

This leads to four practical differences:

  1. Mutability: Application containers are disposable. Their image is immutable, and any change made inside a running container is lost when it is recreated (except the data you keep in volumes). To change an app, you rebuild the image and replace the container. System containers, on the other hand, are meant to be maintained and updated like a VPS or physical server.
  2. Configuration: Application containers are usually described in a file (e.g., a Docker Compose file) and recreated from it. System containers are usually configured by hand from the CLI, like a server, although tools exist to manage them as code too.
  3. Network: An application container usually exposes one service, published on a port of the host. A system container behaves like a separate machine on the network, with its own IP address and as many services as you run on it.
  4. Linux only: The most popular system container managers (e.g., LXD and Incus) only run on Linux hosts. Application containers need a Linux kernel too, but Docker and Podman hide this on macOS and Windows with a built-in Linux VM, so the same image runs almost anywhere.

System container managers like Incus have safer defaults that limit what a compromised container can do to the host. The key one is that containers are unprivileged by default: root inside the container maps to a range of user IDs on the host that owns nothing, not even my files. Docker supports the same mechanism (user namespaces), but leaves it off by default. Rootless Podman uses it too, but maps root to my own user. Incus and Docker also use a seccomp filter to block risky system calls and an AppArmor profile to limit what the container can reach on the host.

This is a real step up from application containers, but it did not fit my plan. I wanted to run Docker apps, and Docker inside a system container only works after you enable security.nesting. I tried it and found it finicky to set up. Even the Incus FAQ lists extra steps: kernel modules loaded from the host, and a /.dockerenv file so Docker ignores some errors. The container also still shares my kernel, so a single kernel bug lets an attacker skip all these layers.

3. Virtual machines

Similar to system containers, virtual machines run full operating systems on top of the host OS. However, they use their own kernel instead of sharing the host's. One convenient way to run VMs on Linux is Incus, which creates them from the same image server and with the same CLI as its system containers. A compromised app must first take over the guest kernel, then escape the hypervisor (QEMU and KVM on Incus), which Incus also confines with AppArmor and seccomp. This attack surface is much smaller than the shared kernel of a container. This isolation comes with a cost: a slower start time and more resource use on the host machine.

Docker also runs inside a VM like on any other server: no nesting, no extra settings.

Side by side, the three groups look like this:

1. Application containers2. System containers3. Virtual machinesHost kernelContainerHost kernelSystem containerHost kernelVirtual machineOne app and its librariesFull Linux OSFull Linux OSOwn kernelMany appsMany apps system callssystem calls QEMU and KVM

So which setup did I end up picking?

Here is the setup I chose:

  • Incus as the VM manager: a package on my Ubuntu, driven from the CLI, with an OpenTofu provider to turn the setup into code later.
  • One homelab VM with its own kernel, so a compromised app or AI agent stays away from my files.
  • CPU, memory and disk limits on the VM, so my PC stays usable for day-to-day tasks.
  • Docker inside the VM: Compose files run as written, and Docker's root is only root inside the VM.
My PCIncus CLIHomelab VM: CPU, memory and disk limitsDockerSelf-hosted appsMy prototypes creates and manages

This setup has its limits. The homelab stops when my PC sleeps, so it is not for services I need all the time. It also shares my PC's only disk: if that disk dies, both go with it. For experiments and prototypes, I can live with that.

In the next post, I will show how I set up the VM with Incus, and how the default network still let my "isolated" VM reach services on my PC and on my home network.