I Revived My Broken MacBook Into My Own Personal Cloud

June 14, 2026
KubernetesHomelabSelf-Hosted

As a tech enthusiast, I have a habit of accumulating "retired" hardware. Over the years, several iPhones and Macs have ended up in drawers, collecting dust. Rather than let them keep collecting dust, I started thinking about how to get some actual use out of them.

So I wanted to build a private cloud, complete with a personal ChatGPT interface (via Open WebUI) and a private "Google Drive/Dropbox" (via OpenCloud). I wanted my data on my own hardware, under my control, rather than in an external cloud.

I looked at a 2017 MacBook Pro sitting in my drawer. The keyboard was broken, but it still ran, so I decided it would be a good machine to set up Kubernetes on.

Setting up Kubernetes

Before anything else could happen, I needed an OS on the machine. I didn't have a spare USB drive handy, so I flashed Ubuntu 24.04 Desktop onto an old Raspberry Pi SD card and used that as a stand-in installer to get Ubuntu onto the Mac. It was slow, but it worked.

The first real hurdle was the hardware itself, specifically getting the Broadcom Wi-Fi drivers working. After trying to modify the official driver source code with Claude's help, I found a script that worked: https://gist.github.com/torresashjian/e97d954c7f1554b6a017f07d69a66374. I set it up to run on every restart, so the Wi-Fi comes back up whenever the Mac boots.

Once online, I enabled SSH for remote management, restricted to my internal network. For updates, I wanted a "GitOps" workflow instead of manual changes.

With SSH reliably reachable, I deployed k3s for lightweight orchestration and connected it to Flux CD. This meant that instead of manual configuration, Flux would pull directly from my GitLab repository and automatically apply any changes to the cluster.

Unfortunately, my new server kept ignoring me whenever I closed the lid. Ofc, this was due to the computer entering sleep mode. So I modified the power settings for Ubuntu by editing /etc/systemd/logind.conf and setting HandleLidSwitch=ignore, to make sure it always kept running. No matter what.

The Network Issues

Once the core system was stable, I needed my domain to actually point at my server. I didn't want to pay my ISP for a static IP, so I wrote a small Python script that checks my public IP every 15 minutes and updates my domain's A records through an API, running as a takk job.

It didn't work though, as I wasn't aware my network was set up using CG-NAT (Carrier-Grade NAT), meaning I didn't have a public IP of my own. It was shared across a whole range of customers behind my ISP's NAT gateway. So the script was correctly updating my domain, but to an IP address that didn't actually lead back to my house.

A quick, slightly humbling phone call to my network provider solved it. Turns out they hadn't opened a single port. They'd taken me off CG-NAT and given me my own public IP, free of charge. They can still swap my IP anytime though, so I kept the script running to keep the server reachable.

I could have kept everything on my local network behind a VPN, but getting a trusted HTTPS certificate without a public endpoint isn't straightforward, and OpenCloud requires HTTPS to function properly.

To get real SSL certificates, I needed a proper public endpoint, which led me to Domene.shop, a local DNS provider with a solid API for my IP-update script and a webhook adaptor for Let's Encrypt. Once my DNS pointed to my host, the cluster could handle SSL certificates automatically.

Managing k8s Manifests

With the network and DNS stable, I started deploying the "big" services, OpenCloud for my files and Open WebUI for my AI.

However, as I added more services, requiring Postgres databases, Redis caches, MinIO storage, and SSL certificates, the Kubernetes YAML became hard to manage. I kept mistyping service names, misconfiguring environment variables, and mismatching metadata.

This is where the project shifted from "playing with k8s" to building an easier to maintain system. To manage this complexity, I turned to takk, an infrastructure-as-code framework that I maintain.

Instead of writing hundreds of lines of brittle YAML, I define my infrastructure in Python. takk handles the heavy lifting. It generates the k8s configs, ensures all secrets and environment variables are consistent across services, and manages the relationships between third-party resources like databases and the apps that need them.

python
from pydantic import PostgresDsn
from pydantic_settings import BaseSettings
from takk import Project, NetworkApp
from takk.secrets import S3AccessKey, S3BucketName, S3Endpoint, S3RegionName, S3SecretKey

class OpenWebUISettings(BaseSettings):
    database_url: PostgresDsn
    s3_bucket_name: S3BucketName
    s3_region_name: S3RegionName
    s3_endpoint_url: S3Endpoint
    s3_secret_access_key: S3SecretKey
    s3_access_key: S3AccessKey
    # Currently using Scaleway for the heavy lifting,
    # but this architecture makes it trivial to point to a local LLM later.
    openai_api_base_url: str = "https://api.scaleway.ai/v1"
    openai_api_key: str = "... "

project = Project(
    name="infra",
    chat=NetworkApp(
        docker_image="ghcr.io/open-webui/open-webui:v0.8.6",
        port=8080,
        settings=[OpenWebUISettings]
    )
)

Connecting Obsidian to My AI

With the infrastructure stable, I hit the most rewarding part of the project.

I had my files in OpenCloud and my AI in Open WebUI, but they were completely separate. My chat interface could talk to me, but it couldn't see my data. I couldn't ask it, "What were my notes on the project from yesterday?"

I needed a bridge. Since there wasn't a standard way to expose OpenCloud files to an LLM, I built one using FastAPI, reading the files straight out of OpenCloud over WebDAV. I created a custom toolset that exposed my Obsidian paths, tags, and backlinks via an API.

That wasn't quite enough on its own. Open WebUI's default function calling mode has the model pick its tool calls upfront and run with them, without looking at what comes back. I switched it to Native mode instead, which lets the model reason about each tool's output before deciding what to do next, so it can look at a result and decide that output means it should call another tool.

With that in place, I could ask my self-hosted AI things like:

Summarise my meeting notes from last week, and if needed create any todos for this week.

Since my infrastructure was already defined in takk, adding this new FastAPI "bridge" just meant adding it to my Project config, and the cluster handled the rest. I even extended this to Neovim, allowing me to get inline AI completions in my terminal that could "read" my Obsidian files.

python
project = Project(
    # ... existing config
    obsidian_tools=FastAPIApp(
        app="src.takk_k8s.obsidian_tools:app",
        settings=[ObsidianToolsSettings],
        compute=Compute(mvcpu_limit=250, mb_memory_limit=256)
    )
)

Monitoring

Once the AI could access my notes, I had no way of knowing if a service went down, and I didn't want to find out my cluster was broken right when I needed a file.

I added two separate things here. A Prometheus MCP (Model Context Protocol) server lets me ask my cluster questions directly, while Alerting is handled by takk itself, generating the alerting rules and notification routing I need. The alerting part is defined directly in my takk Project config, so if a service enters a crash loop or a job fails, I get an email immediately. Everything, from the network to the alerts, is defined in code.

python
project = Project(
    # ... existing config
    alerts=[
        Alert(
            name="CrashLoop",
            query="increase(kube_pod_container_status_restarts_total[1h]) > 2",
            notify=[mats],
            severity="critical"
        ),
        Alert(
            name="FailingJobs",
            query="...",
            severity="critical",
            notify=[mats]
        ),
    ],
)

I also added a k8s MCP server, which made it much easier to debug my cluster. Instead of SSH-ing in and running kubectl, I can chat directly in Open WebUI, making it easy to debug issues such as:

why am I not able to connect to my psql database with this host url?

Debugging a connection issue to the database

What's Running Today

The cluster slowly grew based on whatever I needed next. There's the Obsidian bridge, but also things like calendar tools, and a few others along the way. Even this blog, the one you're reading right now, runs on it as the blog service. The graph below shows all the services and how they depend on each other, generated automatically from my takk Project config.

All services running in my personal cluster, generated from my takk Project config

Network Architecture

With the network, DNS, and services all wired up, here's how everything fits together, from a request hitting my domain to the data landing back on my devices.

graph TD subgraph ext["External"] User[User Devices: Laptop/Mobile] DNS[Domeneshop DNS] LE[Let's Encrypt] end subgraph home["Home Network"] ISP[ISP / Open Port] Mac[MacBook Pro: k3s Cluster] PI[Pi-hole] OC[OpenCloud] OW["Open WebUI"] FA[FastAPI: Obsidian Tools] end User -- "Public URL" --> DNS DNS -- "Resolves to" --> ISP ISP -- "Routes to" --> Mac LE -- "Certificates" --> Mac Mac -- "Local DNS" --> PI Mac -- "Host Services" --> OC Mac -- "Host AI Interface" --> OW OW -- "API Calls" --> FA FA -- "Reads" --> OC OC -- "Syncs Files" --> User classDef external fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:1px; classDef home fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e,stroke-width:1px; classDef hub fill:#0ea5e9,stroke:#0369a1,color:#ffffff,stroke-width:2px,font-weight:bold; class User,DNS,LE external; class ISP,PI,OC,OW,FA home; class Mac hub; style ext fill:#fffbeb,stroke:#f59e0b,stroke-width:1px style home fill:#f0f9ff,stroke:#0ea5e9,stroke-width:1px

Future Updates

What started as clearing out a desk drawer turned into a fully functioning private cloud. I own my data, and I own the code that runs it all.

A few things I'm planning next:

  1. •Local LLM: Setting up a dedicated machine for fully private LLMs (Gemma 4, etc.), and only offload to Scaleway when I need the extra performance.
  2. •Self-Healing: Integrating a coding agent that can monitor the logs and suggest (or apply) fixes to the cluster itself.
  3. •Hardened Backups: Moving from "syncing" to a professional backup strategy using tools like Velero.

If you've done something similar with old hardware, or have suggestions for what to tackle next, let me know in the comments.

Comments