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

The machine under my desk

Eight stacks run on the machine under Portainer, and nothing on it is reachable from the internet.

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’s a gaming PC first. It runs Bazzite and streams games to my Steam Deck and my MacBook most nights. The rest of the time it’s my home server, running 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.


Table of contents

  1. How it’s put together
  2. Why I run one
  3. The pros and cons
  4. Forgejo
  5. Roon and its ripper
  6. Sunshine
  7. Immich
  8. Nextcloud
  9. The books stack
  10. Caddy
  11. dnsmasq
  12. Portainer
  13. The perf-daemon
  14. Source code

How it’s put together

Almost everything on this box runs in containers, grouped into eight stacks with a compose file each. Portainer runs 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.


Why I run one

I enjoy it, first of all. 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.

I also 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. A hosted service can change under me or shut down.

Every piece of it is something I put there, so when a piece breaks I know where to look. If Forgejo falls over, I check its logs, understand why it broke and restart the container.


The pros and cons

Any part of it can be changed to do exactly what I want. The machine can even be taught things about itself, like the perf-daemon further down.

The hardware has to be bought, a desktop that never sleeps adds to the electricity bill every month, and the hours add up. One Bazzite update took my game streaming down. When the machine goes down, it stays down until I fix it. Nobody audits any of this but me either, so dead config piles up.

This machine isn’t reachable from the internet at all. From outside the house I reach it only over Tailscale.

Everything here has exactly one copy unless I make another. The layout of the machine, at least, exists outside it, because every compose file is published at the bottom of this page.


Forgejo

Forgejo is my git server. This site’s entire repository lives in it, and so does the config for every other stack.

It runs as a single container. Port 3000 is its web UI and its API. 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. It also sits behind a bazzite.local hostname.


Roon and its ripper

Roon Server is what plays music in this house. It runs in network_mode: host, because Roon finds the speakers by calling out on the local network and listening for answers.

Alongside it sits a second container I wrote myself. It watches a couple of streaming services and pulls down anything new from artists I follow the moment it’s released. It shares a Music folder with Roon. Anything it grabs shows up in the library immediately.


Sunshine

Sunshine is what streams games from this box to the Steam Deck or the MacBook. One Bazzite system update renamed the session file Sunshine depended on and deleted the native package entirely, with no warning. I’ve since gone back to a manually installed RPM build.

The container Portainer runs is only a thin Alpine shell 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 work properly inside a container. There’s a row of .bak files next to the current config, one per attempt to fix this.


Immich

Immich is the most expensive stack on this box to run, because it takes four containers. The API server is the gallery I browse and the thing my phone uploads to. A machine-learning container runs on the graphics card and works out an embedding for every photo I upload, along with the faces in it. Postgres, with a vector extension, indexes those embeddings. Redis holds the queue of background jobs and carries them between the API and the workers. This is the stack that took over from Google Photos.


Nextcloud

Behind the app itself there’s a Postgres database, Redis for caching and file locking, a Collabora Online container, and Imaginary for thumbnails. My files moved here from Google Drive.

Collabora in particular needs its own reachable addresses written into its settings, because it refuses to serve a document to any address it doesn’t recognise as itself. Both the address on my home network and the Tailscale one are configured in. It cost me an afternoon the first time I hit it.


The books stack

Calibre-Web-Automated is for browsing and reading a proper ebook library, and AudioBookshelf is for audiobooks and podcasts. The third is kobodl, a small tool I wrote a wrapper around. It checks my Kobo account every five minutes and pulls down anything I’ve bought there into a shared ingest folder.

Calibre-Web-Automated watches that folder and files whatever lands in it into the library automatically. AudioBookshelf stays out of that loop and works from its own library folder.


Caddy

Caddy sits in front of everything above. 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.

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 forward to a port where nothing is listening. I haven’t got around to cleaning the Caddyfile up.


dnsmasq

dnsmasq runs here in a container and answers for names like git.bazzite. It’s bound to the Tailscale address only, with listen-address pinned and bind-interfaces set. git.bazzite and home.bazzite resolve from my laptop in a café as well as at home.

It caught me out for a good hour when I came back to 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.


Portainer

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. 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. 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.


The perf-daemon

A graphics card asked to encode a video stream while something else wants the same chip doesn’t sort that out gracefully by default. The perf-daemon is a short Python script that the system starts at boot and keeps alive. It listens to Docker for the moment any container starts or stops and decides from that what the machine should be optimising for right now.

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. When the set of running containers changes, the daemon works out which profile should be active. 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 switches the graphics card into a matching mode. None of that logic exists anywhere as a package I could have installed.

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.