Skip to content
← Posts

November 29, 2025/1524 words/8 min read

My Accidental Homelab

How a cheap M1 Mac Mini became my entire home infra.

Hey, did you know I have a home server? It's a cheap M1 Mac Mini with the base configuration and an external SSD stuck to the back because I refuse to pay Apple's storage tax. It sits velcroed upside down under my desk, doing work I initially thought required a proper rack server, and it's been running quietly for weeks without me thinking about it once.

When I first bought it back in 2020, I really didn't expect much. It was Apple's first attempt at their own silicon in a desktop machine, and early Apple adopters usually get the "privilege" to debug version 1.0 for everyone else. The plan was to use it as a lightweight development machine and maybe run a few Docker containers. Instead, it became my main workstation for about four years, until I upgraded to a newer MacBook Pro. Then it sat there for a few weeks, a perfectly good ARM64 machine drawing less power than my phone, doing nothing. That felt wrong!

Now it runs everything. I started by replacing SaaS apps I was tired of paying for. Then Home Assistant moved in to control the lights and track power usage. The Mini also runs background jobs that sync things and process images, plus a few tools for the missus. Somewhere along the way it became infrastructure I didn't know I needed. Mostly I just enjoy tinkering with it.

I started with Docker because everyone uses Docker and I already knew it. On macOS, though, Docker has always felt kind of wrong. You're running containers inside a Linux VM inside macOS, and you can feel every layer of that stack whenever something doesn't quite work. File mounts are slow, and the VM occasionally decides to eat memory for no clear reason. Updates can break networking in unpredictable ways. It ends up working, but it makes you earn it. I also tried Podman for a while, thinking maybe Docker itself was the problem. Same architecture, same issues! I was still running a Linux VM pretending to be native.

Then at WWDC 2025, Apple announced its Containerization framework and I got excited about infrastructure for the first time in years. Apple kept the scope narrow. It built a native Swift framework for running Linux containers on macOS, without promising to replace Docker or support the entire container ecosystem. The whole thing is open source on GitHub, including the Containerization framework and the container CLI tool. I spent way too long reading through the source code that first weekend.

The architecture is really damn clever. Docker Desktop runs one big persistent Linux VM, while Containerization spins up a lightweight VM for each container. That VM can start in under a second and only uses CPU and memory while its container is doing work. It also gets a dedicated IP address, which gets rid of port-mapping headaches between containers. Storage avoids the usual translation layer too. The filesystem is real EXT4 exposed as a block device, so file access runs at Linux speed.

Inside each VM there's a minimal init system called vminitd, written entirely in Swift. It's a static binary compiled with Swift's Static Linux SDK and linked with musl, with no dynamic libraries. The VM doesn't carry the standard Linux utilities either. vminitd gets the VM ready for a container by bringing up its network and mounting its filesystem. After that, it supervises the container's processes. The whole environment is deliberately stripped down so there's almost no attack surface. Reading the WWDC session notes and source code, I kept thinking, "Oh, they actually thought about this." It feels like the people who built it understood what was annoying about existing container tooling on macOS and fixed the right problems. Thanks Apple, took you long enough.

The container CLI tool is beautifully simple. It supports the short flags you'd expect from Docker (-it, -d, -p, -v, -e, --name), so the muscle memory transfers over immediately.

bash
# Pull an image
container image pull getmeili/meilisearch:latest
 
# Run it interactively
container run -it getmeili/meilisearch:latest /bin/sh
 
# Run detached with port mapping and a master key
container run -d \
  -p 7700:7700 \
  -e MEILI_MASTER_KEY=mysecretkey \
  --name search-engine \
  getmeili/meilisearch:latest

There is no daemon to manage, and Docker Desktop no longer eats 3GB of RAM in the background. A container starts with the command instead of waiting for a shared VM to wake up. It feels like the latency just disappeared.

I migrated services over one weekend and it was almost boring how straightforward it was. The container CLI handled the containers, and each service needed one launchd plist. Here's what the Calibre Web one looks like.

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>local.calibreweb</string>
 
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/container</string>
        <string>run</string>
        <string>--name</string>
        <string>calibreweb</string>
        <string>-v</string>
        <string>/Volumes/Storage/books:/books</string>
        <string>-p</string>
        <string>8083:8083</string>
        <string>ghcr.io/linuxserver/calibre-web:latest</string>
    </array>
 
    <key>RunAtLoad</key>
    <true/>
 
    <key>KeepAlive</key>
    <true/>
 
    <key>StandardOutPath</key>
    <string>/var/log/calibreweb.log</string>
 
    <key>StandardErrorPath</key>
    <string>/var/log/calibreweb.error.log</string>
</dict>
</plist>

Load it once with launchctl load ~/Library/LaunchAgents/local.calibreweb.plist and it keeps running across reboots. Launchd restarts it after a crash and writes its logs to the paths above. This is what service management should feel like! Nobody deserves to go through systemd, Docker Compose, or Kubernetes manifests for a small home lab.

Writing configuration in XML is still miserable, though! The third plist annoyed me enough to write a Python script that generates them automatically. Now I describe everything in a YAML file.

yaml
# services.yaml
services:
  calibreweb:
    image: ghcr.io/linuxserver/calibre-web:latest
    ports:
      - "8083:8083"
    volumes:
      - "/Volumes/Storage/books:/books"
    environment:
      PUID: "1000"
      PGID: "1000"
 
  homeassistant:
    image: ghcr.io/home-assistant/home-assistant:stable
    ports:
      - "8123:8123"
    volumes:
      - "/Volumes/Storage/homeassistant:/config"
 
  meilisearch:
    image: getmeili/meilisearch:latest
    ports:
      - "7700:7700"
    environment:
      MEILI_MASTER_KEY: "change-me-in-production"
      MEILI_ENV: "production"
      MEILI_DB_PATH: "/meili_data/data.ms"
    volumes:
      - "/Volumes/Storage/meilisearch:/meili_data"

Then the script that generates the plists from it.

python
#!/usr/bin/env python3
"""Generate launchd plist files from a services.yaml definition."""
 
import yaml
from pathlib import Path
 
PLIST_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>local.{name}</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/container</string>
        <string>run</string>
        <string>--name</string>
        <string>{name}</string>
        {args}
        <string>{image}</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/var/log/{name}.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/{name}.error.log</string>
</dict>
</plist>'''
 
 
def generate_args_xml(config):
    """Build the XML string elements for ports, volumes, and env vars."""
    args = []
 
    for port in config.get('ports', []):
        args.extend(['-p', port])
 
    for volume in config.get('volumes', []):
        args.extend(['-v', volume])
 
    for key, value in config.get('environment', {}).items():
        args.extend(['-e', f'{key}={value}'])
 
    return '\n        '.join(f'<string>{arg}</string>' for arg in args)
 
 
def generate_plist(name, config):
    """Generate a complete plist string for a single service."""
    return PLIST_TEMPLATE.format(
        name=name,
        args=generate_args_xml(config),
        image=config['image']
    )
 
 
def main():
    with open('services.yaml') as f:
        config = yaml.safe_load(f)
 
    output_dir = Path.home() / 'Library' / 'LaunchAgents'
    output_dir.mkdir(exist_ok=True)
 
    for name, service_config in config['services'].items():
        plist = generate_plist(name, service_config)
        output_path = output_dir / f'local.{name}.plist'
        output_path.write_text(plist)
        print(f"Generated {output_path}")
 
 
if __name__ == '__main__':
    main()

For my use case, this is close enough to Docker Compose. The script turns each YAML service into the definition launchd expects. Once launchd loads the generated plist, I can forget about it.

Calibre Web serves my ebook collection, a few thousand books on that external SSD, from port 8083. I can reach it from any device in the house, and it has been running for weeks without me touching it once. Meilisearch powers search across a couple of my personal apps. It's absurdly fast, and the container uses basically no resources when idle. Home Assistant was the migration that surprised me most. The Docker version used 1.5GB of RAM just existing. On Containerization it uses about 400MB and responds faster. It still controls the lights, with energy and temperature data collected alongside them.

I also built a few personal web apps because I got tired of subscription fees and mediocre UIs. The bookmark manager works the way I think, and the read-it-later service doesn't try to sell me a premium tier. I vibecoded the note-taking app in an hour. It's just Markdown files with search. Each app runs in its own container with a SQLite database mounted from the host. The UIs are mine, and none of them costs another $5 a month.

Nginx sits in front of everything as a reverse proxy, handling TLS and routing requests to the right containers.

nginx
upstream calibreweb {
    server 127.0.0.1:8083;
}
 
upstream homeassistant {
    server 127.0.0.1:8123;
}
 
upstream meilisearch {
    server 127.0.0.1:7700;
}
 
server {
    listen 443 ssl http2;
    server_name books.local;
 
    ssl_certificate /etc/nginx/certs/local.crt;
    ssl_certificate_key /etc/nginx/certs/local.key;
 
    location / {
        proxy_pass http://calibreweb;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
 
server {
    listen 443 ssl http2;
    server_name home.local;
 
    ssl_certificate /etc/nginx/certs/local.crt;
    ssl_certificate_key /etc/nginx/certs/local.key;
 
    location / {
        proxy_pass http://homeassistant;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
 
server {
    listen 443 ssl http2;
    server_name search.local;
 
    ssl_certificate /etc/nginx/certs/local.crt;
    ssl_certificate_key /etc/nginx/certs/local.key;
 
    location / {
        proxy_pass http://meilisearch;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Everything stays local, so there's no external access! That also means I don't have to struggle with VPN complications or elaborate firewall rules. PostgreSQL runs natively via Homebrew for the apps that outgrew SQLite because isolation doesn't buy me anything there. Python scripts handle the boring maintenance, with launchd timers replacing cron because this is macOS and the native tools actually work. The example below triggers a backup at 2 am. Other jobs sync data or process images.

xml
<!-- ~/Library/LaunchAgents/local.backup.plist -->
<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>2</integer>
    <key>Minute</key>
    <integer>0</integer>
</dict>

The M1 is absurdly overpowered for this kind of work. Peak load barely touches 30% CPU, and most of the time it's completely idle. My electricity bill didn't move at all when it started running 24/7. I can check resource usage per container with container stats, and it's super fun to watch how little these things consume. The sub-second start times are wild too. Docker Desktop takes 2-3 seconds to start a container because it's waking up the VM and doing VM things. With Containerization, the process is already there by the time I expect to wait.

And the external SSD was definitely the right call. Apple wants 400 EUR to upgrade from 256GB to 1TB of internal storage. I spent 100 EUR on a 2TB Samsung T7 and stuck it to the back. It's not elegant, but I have way more storage than I'd ever have paid Apple's premium for. The whole setup looks a bit ridiculous if you flip the desk over, but it works! Nobody sees it, which is frankly the best thing about infrastructure.

I also didn't bother setting up monitoring dashboards. I thought about Prometheus and Grafana for maybe five minutes, then realized I could check the logs when something breaks, which is almost never. When a service does stop working, the logs tell me why. I look at /r/homelab and see full racks connected with dedicated networking gear, and honestly I don't understand who has the time. Some of those setups are beautiful pieces of engineering, but they also look like a second job.

Containerization is still really new and the ecosystem is tiny compared to Docker. There's no Compose equivalent yet, though the community is working on one. When something goes sideways, there are fewer guides and Stack Overflow answers, and an LLM will be just as lost as you. For a single-machine homelab where I control everything, the tradeoff still works. Each container starts with 4 CPU cores and 1GB of RAM by default, which I can tune per service with --cpus and --memory.

The Mini cost 600 EUR, though I'm not sure it still counts after serving two full years as my main workstation. The SSD added another 100 EUR, and setup took maybe a weekend. Since then it's just worked. I spend my limited free time building things instead of babysitting the machine under my desk.