Opens a larger view. Escape closes it.
17 min read

The machine under my desk

One box under my desk runs eight things through Portainer: a git server, a music library, a photo library, a file-sync setup, game streaming, an ebook pipeline, and the plumbing that ties them together by name. This is why I run it, what it costs, and how each piece works.

There’s one machine under my desk. It’s an ordinary desktop PC, built around an AMD Ryzen 7 9850X3D processor and an RX 9070 XT graphics card, and it runs Bazzite, which is a version of Linux made for gaming. Most nights it streams games to my Steam Deck and my MacBook. The rest of the time it works as a home server - a computer in the house that runs the services I’d otherwise rent from a company. On this one that means my git server, this website, my music library and the thing that keeps it stocked, a photo library, a file-sync setup, and an ebook pipeline. On top of that there’s the plumbing that lets me reach all of it by name instead of by a port number I’d never remember.

Before the tour of each piece, three things are worth setting out: what a machine like this is and how it’s put together, why I run one, and what it costs against what it gives.


Table of contents

  1. What a home server is
  2. Why I run one
  3. What it gives, and what it costs
  4. Forgejo, the git server everything else answers to
  5. Roon and its ripper, the music side of things
  6. Sunshine, game streaming and the one that has given me the most grief
  7. Immich, my photo library
  8. Nextcloud, file sync and office documents
  9. The books stack, three services working as one pipeline
  10. Caddy, the reverse proxy
  11. dnsmasq, one DNS server that only answers on one interface
  12. Portainer, the thing that runs all of the above
  13. The perf-daemon, the one piece of software here that isn’t a package
  14. Why I keep doing this instead of paying someone else to
  15. Source code

What a home server is

Self-hosting is the practice of running a service on a computer you own instead of using the same service from a company’s computers. When you keep your photos in Google Photos, the photos sit on Google’s machines and Google’s software sorts them. When you keep them on a home server, the photos sit on a disk in your house and software you installed does the sorting. The service is the same idea in both cases. What changes is whose machine it runs on, and therefore who can change it, charge for it, or switch it off.

Almost everything on this box runs in containers. A container is a program running in its own small, sealed-off world, with every file it needs packed in with it. It can’t see or disturb the rest of the machine, and the machine doesn’t have to be set up specially for it. That’s what makes it practical to run a dozen unrelated services on one PC without them treading on each other. A service usually needs more than one container. My photo library, for example, is a web server, a database, a cache and a machine-learning worker. Those four are described together in one file, called a compose file, which says what each container is, where its data lives on disk, and which port it listens on. A group of containers described in one compose file is called a stack.

Portainer is the program that runs the stacks. It reads each compose file, starts and stops the containers, restarts them when they fall over, and gives me a web page where I can see at a glance what’s running and what isn’t. There are eight stacks on this machine, and Portainer manages all eight. Around them sit two pieces of plumbing. One turns the port numbers the services listen on into names I can type, such as git.bazzite. The other makes those names work from my laptop wherever it is. Each of these gets its own section below.


Why I run one

The plain reason is that I enjoy it, which is why this write-up lives under Play and not under my work. Building the machine, wiring the services together and fixing them when they break is the kind of tech project I spend my free time on because I want to.

The second reason is that I wanted my own things on my own server. I wanted to move away from Google Drive and keep my files on a machine I own, and I wanted my photos out of Google Photos and in a library that runs in the house. That’s a wish about privacy, partly, but mostly it’s a wish about control, and an old argument about software explains it better than I can. You can have control over a program you run yourself, the argument goes, but you can never have control over a service that someone else runs. So wherever running a program would do the job, run the program. A hosted service can change its price, drop a feature or shut down, and there’s nothing to be done about it from the outside. A program on my own disk keeps working until I decide otherwise.

The third reason only shows up once the machine has been running for a while. Every piece of it is something I put there, so when a piece breaks I know where it is and roughly why. That’s worth more to me than any single feature, and the last section of this page comes back to it.


What it gives, and what it costs

What it gives is easy to list. The data stays in the house, on hardware I can point to. Nothing here can raise its price or disappear in a product announcement. Any part of it can be bent to fit exactly what I want, which no rented service allows, and the machine can be taught things about itself that no service would ever know - the perf-daemon section below is that idea in a short Python script. And every hour spent on it teaches me something about networks, storage or Linux.

The costs are just as real, and it’s worth being honest about them. It isn’t cheaper than renting. The hardware has to be bought, a desktop that never sleeps adds to the electricity bill every month, and the hours add up. Once I count my own hours, the hosted versions win on money every time.

Things break on other people’s schedules. An update to the operating system once renamed a file that my game streaming depended on and removed the package outright, with no warning, and the section on Sunshine below is largely the story of recovering from that. Nothing gets audited except by me, so small rot accumulates, like the routes in my proxy config that still point at services I removed months ago. And when the machine goes down, it stays down until I fix it, because there’s no one else to call.

Security is my problem too. Anything left open to the internet gets found. A few years ago several thousand home storage boxes that had been left reachable from outside were locked by ransomware in a single wave, and their owners were asked for money to get their files back. So I don’t open this machine to the internet at all. From outside the house I reach it over a private network between my own devices, and nothing else can.

There’s one more cost that people learn the hard way: backups are my job as well. A service keeps copies of your data in places you never see. Here, everything has exactly one copy unless I make another. The rule of thumb is three copies, on two different kinds of storage, with one of them out of the house. The layout of the machine, at least, exists outside the machine, because every compose file is published at the bottom of this page with the passwords swapped out.


Forgejo, the git server everything else answers to

Git is the tool programmers use to keep every version of a project and the history of who changed what, and a git server is the place where those projects live so that any computer can fetch them. Forgejo is a self-hosted git server, a community fork of Gitea, and it’s the one piece of infrastructure here I’d call load-bearing. This site’s entire repository lives in it, and so does the config for every other stack, because I wanted the server’s own definition to live on the server rather than scattered across whatever laptop I happened to be using when I set something up.

It runs as a single container. Port 3000 is its web UI and its API, which is the address other programs use to talk to it. Git also speaks over SSH, the encrypted way of logging into a machine, and since the box’s own SSH port 22 is spoken for, git-over-SSH is on port 2222 instead. Its config is mostly locked down, with registration disabled, since it’s a server of one. It also sits behind a bazzite.local hostname rather than a raw IP address, which matters more than it sounds like once I’m juggling a dozen services and don’t want to remember which one lived on which port.


Roon and its ripper, the music side of things

Roon Server is what plays music in this house. It runs in network_mode: host, which means the container shares the machine’s own network connection instead of hiding behind a private one with a few ports opened through. Normally a container gets that private network for safety. But Roon finds the speakers in the house by calling out on the local network and listening for answers, and from behind a private network it would never hear them.

Alongside it sits a second container I wrote myself. It watches a couple of streaming services and quietly pulls down anything new from artists I follow the moment it’s released, so the library Roon serves from stays current without me noticing new releases and going to fetch them by hand. It shares a Music folder with Roon, so anything it grabs shows up in the library immediately, with no import step and no manual scan.


Sunshine, game streaming and the one that has given me the most grief

Sunshine is the program that captures what’s on the screen, encodes it into a video stream, and sends it to whatever I’m holding, the Steam Deck or the MacBook, which sends my controller inputs back. It looks the way it does now because of the update I mentioned above. At one point a Bazzite system update renamed the session file it depended on and deleted the native package entirely, on Bazzite’s own schedule, with no warning. I’ve since gone back to a manually-installed RPM build, which is a package I fetch and install myself rather than whatever the distro ships.

That’s also why this stack looks odd next to the others. The container Portainer runs is only a thin Alpine shell, a tiny Linux image, whose one job is to run a manager script that starts Sunshine, watches it, and restarts it if it dies. Sunshine itself lives on the host, outside any container. The part of Linux it uses to grab the screen doesn’t take kindly to being boxed in, and containerising it turned out to be more trouble than it was worth. There’s a row of .bak files next to the current config, one per attempt to fix this.


Immich, my photo library

Immich is a self-hosted alternative to Google Photos, and it’s the most expensive stack on this box to run, because it takes four containers to do what looks like one job. There’s the API server, which is the gallery I browse and the thing my phone uploads to. There’s a Postgres database, with an extension that can store the “fingerprint” of each photo and find the ones that look alike. There’s a Redis cache, a small fast store that holds the queue of background jobs. And there’s a separate machine-learning container that runs on the graphics card and works out those fingerprints, called embeddings, for every photo I upload, along with the faces in them.

The four hand work to each other. The ML container computes the embeddings, Postgres indexes them so that “show me photos like this one” is a quick question, Redis carries the jobs between the API and the background workers, and the API server ties it all together into the gallery. This is the stack that took over from Google Photos.


Nextcloud, file sync and office documents

Nextcloud is the closest thing on this machine to a personal Dropbox and Google Docs in one, and it’s a heavier stack than it looks from the outside. There’s the Nextcloud app itself and a Postgres database behind it. There’s Redis, for caching and for file locking, so that two devices can’t overwrite the same file at once. There’s a Collabora Online container, which is an office suite that runs in the browser, so I can edit documents without downloading them. And there’s a small image service called Imaginary that Nextcloud calls out to for thumbnails. This is the stack my files moved to from Google Drive.

Collabora in particular needs to know its own reachable addresses baked into its settings. Both the address on my home network and the address on my private network are configured in, because it refuses to serve a document to any address it doesn’t recognise as itself. That’s a very reasonable security default, and it cost me an afternoon the first time I hit it.


The books stack, three services working as one pipeline

This stack is three containers working together. Calibre-Web-Automated is for browsing and reading a proper ebook library. AudioBookshelf is for audiobooks and podcasts. The third is kobodl, a small tool I wrote a wrapper around: every five minutes it checks my Kobo account, the ebook shop I buy from, and pulls down anything bought there into a shared ingest folder.

Calibre-Web-Automated watches that folder and files whatever lands in it into the library automatically. So the three form a pipeline rather than three separate tools, where kobodl fetches, Calibre-Web-Automated organises and serves, and AudioBookshelf handles the audio side separately from its own library folder.


Caddy, the reverse proxy

Every service above listens on a port, which is a number, and nobody wants to remember numbers. Caddy is a reverse proxy, which is a server that stands in front of the others: you ask it for a name, and it quietly passes the request on to whichever service that name belongs to. So home.bazzite, bazzite.local and bazzite all bring up a homepage that Caddy serves straight from disk. A handful of named addresses, git.bazzite among them, each forward to the right local port, so I type a name rather than an IP address and a port number I’d have to look up every time.

Its config still has rules pointing at services from an old media stack I tore out a while back (jellyfin.bazzite, sonarr.bazzite, radarr.bazzite, prowlarr.bazzite and jellyseerr.bazzite). Those routes just fail quietly now, forwarding to a port where nothing is listening. I haven’t got around to cleaning the Caddyfile up, and this is the small rot I meant earlier - it accumulates in something I built for myself and nobody else ever audits.


dnsmasq, one DNS server that only answers on one interface

For a name like git.bazzite to work, something has to translate it into an address, and that something is a DNS server. dnsmasq is a small one that runs here in a container. The twist is the private network. Tailscale connects my own devices to each other over the internet through encrypted links, so my laptop can reach this machine from anywhere as if it were on the home WiFi. The dnsmasq container is bound to that private network’s address only, with listen-address pinned and bind-interfaces set, so it won’t answer a question that arrives from anywhere else. That’s deliberate. It’s what makes git.bazzite or home.bazzite resolve from my laptop in a café, not just at home.

It caught me out for a good hour the first time I touched it after not looking at the config in a while. The address a name should resolve to depends on which network the question comes in from, so there’s a second, separate dnsmasq configuration for the home network side, doing a similar job on purpose, and I have to remember that the two are different things entirely.


Portainer, the thing that runs all of the above

Everything above is a stack, and Portainer is what applies them, restarts them, and gives me a web page for checking container health without logging in over SSH and running docker ps every time. Its own compose file is the one file not under its management, because it has to exist before Portainer does.

The compose files live under Portainer’s own data directory, one folder per stack. I keep a mirrored copy of each one committed to Forgejo, and the copies at the bottom of this page, with passwords swapped for placeholders, are taken from the live ones, along with the scripts that hold Sunshine together and the perf-daemon itself. They’re shaped by this particular box and I wouldn’t call them a template, but for anyone putting together something similar, they’re available for drawing inspiration.


The perf-daemon, the one piece of software here that isn’t a package

None of the above is the actual engineering problem, though. The problem is that this same box is what I stream games from most nights, and a graphics card asked to encode a video stream while something else wants the same chip doesn’t sort that out gracefully by default. A daemon is a small program that runs in the background all the time. This one is a short Python script that the system starts at boot and keeps alive. It listens to Docker, the container engine, for the moment any container starts or stops, and from that it decides what the machine should be optimising for right now.

It’s simple. There’s a fixed list that maps container names to kinds of work: Sunshine means gaming, Ollama and Open WebUI mean a language model is running, and Jellyfin and Tdarr mean video. There’s a priority order, gaming over the language model over video over the balanced default, so that if two things start at once the more important one wins. When the set of running containers changes, the daemon works out which profile should be active, and if that differs from what’s applied it calls a second script. That script tells the processor how hard to push (performance for gaming, balance_performance for the language model or video, and power when the machine is idle), and it switches the graphics card into a matching mode. None of that logic exists anywhere as a package I could have installed. It exists because no stock config on this machine knew that “Sunshine just started” should mean something different from “Tdarr just started a transcode”, and nothing was going to teach it that except me writing it down.


Why I keep doing this instead of paying someone else to

The question I get asked most about all of this is “why not just use the hosted version of whichever thing this is”. Most of the answer is in the sections at the top: I enjoy it, I wanted my own things on my own server, and I’d rather have control over a program I run than over a service someone else runs. But the honest core of it is that when something breaks here, I know exactly where to look. If Forgejo falls over, I check its logs and restart the container, and I understand why it broke, because I’m the one who built the thing that broke. If a hosted service goes down, changes its pricing or gets quietly shut down, I have no lever to pull at all, because the structure underneath it was never mine to begin with. I wrote something close to this once about order and chaos more generally - the complexity you build yourself stays legible in a way that complexity you inherit never does.

Source code

home-server

The compose files, configs and scripts behind everything on this page (Forgejo, Roon and its ripper, Sunshine, Immich, Nextcloud, the books stack, Caddy, dnsmasq, Portainer and the perf-daemon), taken from the box that runs them, with a README in each folder on what that stack does and how its files fit. Passwords, account names and addresses are placeholders.