Contents

You Don't Need Kubernetes, You Need a systemd Unit

The orchestration everyone reaches for solves a scaling problem most homelabs have never had

Contents

Every homelab forum eventually has the same thread: someone asks how to run three or four self-hosted services reliably, and someone else answers “Kubernetes.” It’s well-meant advice and it’s usually wrong for the question being asked. Kubernetes exists to solve problems of scale and fleet management — scheduling workloads across many machines, healing around hardware failures you don’t control, coordinating hundreds of engineers deploying independently. A home server running Jellyfin, a couple of *arr containers and a reverse proxy has none of those problems. What it has is a much smaller, much older one: keep a process running, restart it if it dies, and start it again after a reboot. systemd has been solving exactly that since before Kubernetes existed, on every Linux box already, for free.

What Kubernetes actually earns its complexity solving

Advertisement

It’s worth being fair to Kubernetes before dismissing it for this use case, because the problems it solves are real — for the scale it was designed for. If you’re scheduling workloads across a dozen physical machines and need the scheduler to figure out which node has spare capacity, Kubernetes does that. If a node dies and you need workloads automatically rescheduled onto surviving hardware without a human deciding where, Kubernetes does that too. If you have multiple teams deploying independently to a shared set of machines and need namespacing, resource quotas and RBAC to keep them from stepping on each other, that’s squarely Kubernetes’ problem to solve.

None of that describes a single machine, or even two or three, running a fixed and well-known set of self-hosted services that one person manages. There’s no scheduling decision to make when there’s only one place a container can run. There’s no multi-tenant isolation problem when the “tenant” is one household. Running Kubernetes for this is building a distributed scheduler to solve a problem that has exactly one candidate answer every time.

The cost isn’t hypothetical, either. A Kubernetes control plane — the API server, etcd, the scheduler and controller manager — is itself a set of processes that need to stay up, get patched, and occasionally get debugged when a certificate expires or etcd’s disk fills up, on top of whatever workload you actually wanted to run. k3s and other lightweight distributions trim a lot of that overhead, and they’re a reasonable choice the moment you do have more than one node worth scheduling across. They don’t remove the fundamental shape of the thing: a second layer of software whose entire job is deciding where your one real workload runs, on a machine where there was never more than one place it could have run anyway.

What systemd actually does, and why it’s already enough

A systemd unit is a declarative description of a process: what to run, when to start it, what to do if it dies, and what it depends on. That’s a smaller feature set than Kubernetes by a wide margin, and it’s also precisely the feature set a home server actually exercises day to day.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Unit]
Description=Jellyfin Media Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/jellyfin
Restart=on-failure
RestartSec=5
User=jellyfin

[Install]
WantedBy=multi-user.target

Read this against what Kubernetes would need to express the same intent, and the difference isn’t capability, it’s ceremony. After=network-online.target says don’t start until the network is actually up — the exact class of race condition that trips up a lot of homelab setups on boot, service starting before DNS or the network interface is ready, failing, and never being told to try again. Restart=on-failure with RestartSec=5 is the crash-loop protection people reach for a container orchestrator to get: if the process dies, wait five seconds and start it again, indefinitely, without a human watching. WantedBy=multi-user.target means it starts automatically on boot, no separate init system or cron entry needed.

Every one of these lines maps to something Kubernetes also does — restart policies, readiness gating, boot-time reconciliation — except systemd is already installed, already running, and requires no additional software, no separate control plane, and no YAML dialect on top of the YAML you’re already trying to avoid.

Dependencies between services: systemd’s ordering versus Kubernetes’ readiness probes

Advertisement

The place people assume they need an orchestrator is service dependencies — “don’t start the app until the database is actually accepting connections,” not just running. systemd handles ordering (After=, Before=) natively, but ordering alone only guarantees the database process has started, not that it’s finished initialising and is ready to accept queries. This is the same liveness-versus-readiness distinction that shows up in load balancing, and systemd has a blunter tool for it than Kubernetes’ readiness probes: a small wrapper script that polls the dependency before handing control to ExecStart, or Type=notify if the service itself is willing to tell systemd when it’s actually ready via sd_notify. It’s less elegant than a first-class readiness probe. For two or three services with a fixed, known dependency order, it’s also more than sufficient, and it’s one config block rather than a second YAML manifest describing a probe.

Where Podman quadlets close the actual gap people want Kubernetes for

The honest reason people reach past systemd isn’t process supervision, it’s that they’re already running everything in containers and want that supervision to understand containers specifically — pulling images, mounting volumes, handling networks — rather than just launching a bare binary. That gap is real, and it’s also already closed without Kubernetes: Podman quadlets let you describe a container as a systemd unit file, and systemd manages its entire lifecycle — start, stop, restart on failure, start on boot — using exactly the unit syntax above with a few container-specific fields added. I’ve written about running containers as native systemd units with Podman quadlets in more depth, and it’s the closest thing to “Kubernetes’ restart guarantees, without Kubernetes” that exists for a single-host setup.

Resource limits: the same cgroups, without the extra abstraction layer

Kubernetes’ resource requests and limits get treated as a uniquely Kubernetes feature, but they’re a YAML-shaped interface to a Linux kernel mechanism — cgroups — that systemd uses directly, without an intermediate scheduler translating your intent into kernel calls. A unit file can cap CPU and memory just as concretely as a pod spec can:

1
2
3
[Service]
MemoryMax=512M
CPUQuota=50%

MemoryMax sets a hard ceiling enforced by the kernel’s cgroup memory controller — cross it and the process gets killed by the OOM killer scoped to that cgroup, not the whole machine. CPUQuota=50% caps the service to half of one core’s worth of CPU time, the same throttling mechanism Kubernetes uses under the hood when you set a CPU limit on a pod. The difference isn’t what’s enforcing the limit. It’s that Kubernetes’ version goes through an API server, a scheduler, and a kubelet translating a manifest into exactly this cgroup configuration, while systemd writes it directly. For a fixed set of services on a machine you already know the specs of, that translation layer isn’t buying you anything — you already know what “512 megabytes” means on this specific box, because there’s no scheduler deciding which of several candidate nodes gets to enforce it.

Timers: a cron replacement that already understands dependencies

The other job people reach for extra tooling to solve is scheduled work — a nightly backup, a periodic cleanup script, a certificate renewal check. systemd timers do this natively, and they solve a problem plain cron never did: a timer can depend on a target being reached, log its output through journald alongside the rest of your services, and be inspected with the same tools (systemctl status, systemctl list-timers) you already use for everything else, rather than a separate crontab -l you have to remember to check.

1
2
3
4
5
6
7
# backup.timer
[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true is the detail that actually matters for a home server that isn’t always on: if the machine was asleep or powered off when the timer would have fired, systemd runs the missed job on next boot instead of silently skipping it — the exact failure mode a plain cron entry has no answer for at all, since cron simply has no concept of a job it missed.

Socket activation: starting a service only when something actually wants it

One systemd feature that has no tidy Kubernetes equivalent at all is socket activation — systemd opens and listens on a port itself, on the service’s behalf, and only actually starts the service process the first time a connection arrives. Until then, the service isn’t running, consuming no memory and no CPU, but the port still appears open and connections queue rather than get refused. This is how a lot of infrequently used services on a home server can sit completely idle — no process, no memory footprint — right up until the moment they’re actually needed, at which point systemd starts the process, hands it the already-established connection, and the caller never sees a delay beyond the one cold start.

1
2
3
4
5
6
# myapp.socket
[Socket]
ListenStream=8080

[Install]
WantedBy=sockets.target

Pair this with a matching myapp.service that has no [Install] section of its own, and the service only ever starts on demand. It’s a genuinely different capability from anything a container orchestrator offers by default, because Kubernetes has no equivalent notion of “don’t schedule this pod at all until traffic for it actually arrives” — the closest analogues (scale-to-zero add-ons, serverless frameworks bolted on top) are themselves extra software solving a problem systemd already handles as a built-in primitive.

Troubleshooting: when a plain unit isn’t behaving

The most common surprise is a service that “works when I run it by hand” and fails under systemd. This is almost always an environment difference — systemd units run with a minimal, explicit environment, not your interactive shell’s PATH or environment variables, so anything the process relied on implicitly from your shell profile needs to be set explicitly in the unit with Environment= or an EnvironmentFile=.

The second is a restart loop that never stabilises — Restart=on-failure retrying every five seconds, forever, against a process that fails instantly every time. systemd has a built-in circuit breaker for this: StartLimitIntervalSec and StartLimitBurst cap how many restart attempts are allowed within a window before systemd gives up and marks the unit failed rather than looping indefinitely, which is worth setting explicitly rather than leaving at the defaults, since the default burst limit is more generous than you’d want for a genuinely broken service.

The third is ordering that looks correct but doesn’t actually wait long enough — After= guarantees start order, not readiness, as covered above, and the fix is either a small wait-for script in ExecStartPre, or accepting Restart=on-failure as the safety net: the app fails its first attempt to reach a dependency that isn’t ready yet, systemd restarts it five seconds later, and by then the dependency usually is.

The fourth is a unit that appears to start successfully but the service still isn’t reachable, which usually turns out to be a Type= mismatch. Type=simple, the default, tells systemd the process is running the moment ExecStart launches, whether or not it’s actually finished initialising — fine for something that binds its port instantly, misleading for anything with a warmup phase. Type=notify fixes this properly if the application supports sd_notify, telling systemd explicitly when startup is complete rather than guessing from the fact that the process exists. Where the application doesn’t support it, Type=forking with a correctly specified PIDFile= at least gets the parent/child relationship right for daemons that fork away from the process systemd originally launched.

Is systemd actually enough, or is this just contrarianism

It’s a genuine question of scale rather than principle. If you’re one person running a fixed set of services on one or two machines, systemd, or Podman quadlets if everything’s containerised, gives you restart-on-crash, start-on-boot, and explicit ordering — which is the actual list of things people cite Kubernetes for at home — with a fraction of the moving parts and none of the separate control plane to keep patched and running. The moment you’re genuinely scheduling across many nodes, need automatic failover onto different hardware, or are coordinating deploys across a team, that’s a different problem and Kubernetes is the right tool for it — I’ve made the case elsewhere for why a Kubernetes cluster is a very expensive while true loop when that scale genuinely isn’t there, and for picking Proxmox over Kubernetes for a home lab’s actual mess of workloads when a scheduler is solving a problem you don’t have. A systemd unit file is twelve lines. Start there, and only reach further once twelve lines has genuinely stopped being enough.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.