My Cloud Exit (Raspberry Pi Edition)

If you’ve ever worked with me, you know I’m extremely serverless pilled.

If you ask me what the right tool for the job is, 99 times out of 100 I’ll say: use the platform and spend your innovation tokens wisely.

But today, I decided to do the opposite.

I wanted to run my personal website at home, on my own hardware, in the UK. Not “in the cloud, but the region is London”. Like… actually in my house.

The temptation to do wrangler deploy and be done is enormous (Cloudflare is literally my employer and it is quite marvelous). But my friend Sean Tracey gave me a Raspberry Pi, and that was enough to ruin my financial judgement.

This post is a guide for my friends who also want to do something deeply impractical: deploy their website to a Pi.

It’s not about cost savings. It’s about learning, vibes, and the joy of knowing your blog is running three meters from your kettle.


The Architecture

What we’re building:

  • A Raspberry Pi running Raspberry Pi OS
  • k3s (lightweight Kubernetes)
  • Your website as a container image (I’m using Bun)
  • Traefik (ships with k3s) for LAN ingress
  • Cloudflare Tunnel to expose it to the internet without port-forwarding

If that sounds like a lot: it is. But it’s also weirdly approachable.


What You Need

Hardware:

  • Raspberry Pi 4 or 5 recommended (4GB+ is necessary)
  • MicroSD card (or SSD if you’re fancy)
  • Ethernet is ideal, Wi-Fi works

Accounts:

  • A domain on Cloudflare (Tunnel is easiest there)
  • GitHub account (I push images to GHCR)

Laptop:

  • Docker (or another container builder)

Step 1: Flash the Pi

I’m not going to pretend I can explain a GUI installer better than a YouTube video.

Watch this and do exactly what they do:

https://www.youtube.com/watch?v=O4IQE2E8oOw

While you’re in Raspberry Pi Imager’s advanced settings, please do these two things (everything else is vibes):

  • Set a hostname (I used berry)
  • Enable SSH with a public key (password SSH is bad)

Pi Imager: paste your public key.

If you don’t know what that means, ask a friend.

(ps. your friend is me.)

Boot the Pi and SSH in:

ssh [email protected]

First thing I do on a fresh Pi:

sudo apt update
sudo apt upgrade -y
sudo reboot

Step 2: Install k3s

SSH back in after reboot:

ssh [email protected]

Install k3s:

curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644

That --write-kubeconfig-mode 644 bit is optional, but it makes life nicer because it lets you read /etc/rancher/k3s/k3s.yaml without sudo.

Check it’s alive:

sudo systemctl status k3s
kubectl get nodes

If k3s is working, you should see one node (your Pi) and after ~30 seconds it should go Ready.

Make kubectl nice

If kubectl get nodes doesn’t work for you yet, don’t fight it.

Ask Claude to help you configure it properly, then add an alias so you can control the Pi cluster from your laptop.

Here’s the prompt I’d use:

I have a Raspberry Pi running k3s. I can SSH to it as `[email protected]`.

I want `kubectl` on my laptop to talk to the Pi cluster without sudo.

Please give me exact commands to:
- copy the k3s kubeconfig from the Pi to my laptop
- fix the server address inside the kubeconfig (so it doesn’t point at 127.0.0.1)
- put it somewhere sensible under ~/.kube/
- add a zsh alias called `kpi` that uses this kubeconfig

Assume k3s kubeconfig lives at /etc/rancher/k3s/k3s.yaml

At this point I hit my first hurdle: k3s wasn’t starting.

Not “something is slightly misconfigured” not starting.

The kind where you stare at systemctl status like it’s going to apologize, and then you start bargaining with the universe.

So I did what every modern engineer does: copied the error, sent it to Claude, and pretended I understood the answer.


Step 3: Enable cgroups (the “why isn’t this working” fix)

On Raspberry Pi OS, k3s sometimes needs memory cgroups enabled.

Instead of me explaining cgroups (and inevitably getting one detail wrong), just ask Claude directly.

Here’s the prompt:

I'm installing k3s on a Raspberry Pi 4 (Raspberry Pi OS). k3s fails to start and the error mentions cgroups / memory cgroups.

1) Explain what cgroups are in the context of Kubernetes.
2) Tell me exactly how to enable the required cgroup settings on Raspberry Pi OS.
3) Give me a safe, repeatable command sequence.

Once the AI has fixed your shit: kubectl get nodes. One node, probably berry, status Ready. Congrats — you run a tiny datacenter now.


Step 4: Containerize the Site (and get mad at Node)

I started with a normal Node setup: TypeScript, Hono, tsx, Tailwind. Then I tried to write a Dockerfile and remembered why “JavaScript tooling” is a phrase that should come with a warning label.

I wrote a whole separate rant about this (including the before/after Dockerfiles): Vertical Integration Wins.

I switched to Bun so I could build a single binary with bun build --compile.

Here’s the Dockerfile I ended up with:

The big win: the final image has no node_modules and no Node runtime. Just a binary.


Step 5: Build + Push the Image (GHCR)

I use GitHub Container Registry because it’s easy and I’m already on GitHub.

Make the package public

Simplest option: make your GHCR package public.

Then your Pi can pull ghcr.io/ankcorn/ankcorn.dev:latest without any auth, secrets, or extra ceremony.

If you want to keep it private (fair), you’ll need to set up imagePullSecrets with a GitHub token. I’m not covering that here because this post is about getting your site online, not becoming an adult.

Build + push in CI

This repo (https://github.com/ankcorn/ankcorn.dev) builds and publishes to GHCR on every push to main.

Workflow file:

  • https://github.com/ankcorn/ankcorn.dev/blob/main/.github/workflows/docker.yml

It publishes a multi-arch image (linux/amd64 + linux/arm64) so the same tag works on my Pi and on normal computers.

So the “deploy” loop becomes:

  • push a commit
  • wait for Actions to go green
  • your cluster pulls :latest

Step 6: Deploy the App

Create a file called ankcorn-dev.yaml on the Pi:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ankcorn-dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ankcorn-dev
  template:
    metadata:
      labels:
        app: ankcorn-dev
    spec:
      containers:
        - name: ankcorn-dev
          image: ghcr.io/ankcorn/ankcorn.dev:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: ankcorn-dev
spec:
  type: ClusterIP
  selector:
    app: ankcorn-dev
  ports:
    - port: 3000
      targetPort: 3000

Apply it:

kubectl apply -f ankcorn-dev.yaml
kubectl get pods
kubectl get svc

If the Pod is Running, you’re cooking.


Step 7: Local Ingress (LAN URL)

k3s ships with Traefik, so we can add a normal Kubernetes Ingress.

Append this to ankcorn-dev.yaml:

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ankcorn-dev
spec:
  ingressClassName: traefik
  rules:
    - host: berry.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: ankcorn-dev
                port:
                  number: 3000

Re-apply:

kubectl apply -f ankcorn-dev.yaml
kubectl get ingress

Now, on your laptop (on the same network):

  • Visit http://berry.lan/

Step 8: Expose It to the Internet (Cloudflare Tunnel)

This is the final hurdle.

You could port-forward. You could configure firewalls. You could spend a weekend learning NAT traversal.

Or you could do what every sane person does: use Cloudflare Tunnel.

9.1 Create the tunnel

Follow Cloudflare’s doc:

https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/

At the end, you’ll get a tunnel token.

9.2 Store the token in Kubernetes

On the Pi:

kubectl create secret generic cloudflared-token \
  --from-literal=token="YOUR_TUNNEL_TOKEN"

9.3 Run cloudflared in the cluster

Create cloudflared.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args:
            - tunnel
            - --no-autoupdate
            - run
            - --token
            - $(TUNNEL_TOKEN)
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: cloudflared-token
                  key: token

Apply:

kubectl apply -f cloudflared.yaml
kubectl get pods

9.4 Point your hostname at the service

In the Cloudflare Tunnel dashboard, add a Public Hostname:

  • Hostname: yourdomain.com
  • Service: http://ankcorn-dev:3000

Because cloudflared runs inside your cluster, it can talk directly to the ClusterIP service by name.

Now your website is live.

Bonus: sanity checks

A couple commands I always run right after:

kubectl get pods -o wide
kubectl logs deploy/ankcorn-dev --tail=50
kubectl logs deploy/cloudflared --tail=50

If something’s broken, 90% of the time it’s one of:

  • wrong CPU architecture (linux/arm64 vs linux/amd64)
  • wrong image tag (you pushed :pi but deployed :latest)
  • GHCR auth (secret is wrong / token expired)

So, Was This Worth It?

Yes, in the only way hobby projects can be worth it.

What I got out of it:

  • I’m now way more comfortable with Kubernetes primitives (Deployment/Service/Ingress/Secrets)
  • I learned just enough networking to be dangerous
  • Cloudflare Tunnel is basically magic
  • AI help turned the tedious bits from hours to minutes without stealing the learning

What I did not get:

  • High availability
  • Convenience
  • The ability to unplug the Pi without consequences

Things To Do Next

I’m trying to keep this project from turning into a full-time job, but there are a few upgrades that are genuinely worth doing:

  • Uptime monitoring
  • Basic metrics
  • Analytics

None of this is required to get the site live. But it does make it feel like a Real System™.


Cost / Benefit (aka “justify this to yourself”)

Costs:

  • Raspberry Pi: ~£60–£100 depending on model/RAM (mine was a gift, so I paid in friendship)
  • Electricity: a Pi sips power (call it ~£5–£15/year depending on your rates)

Alternatives:

  • wrangler deploy: basically free
  • A $5 VPS: boring and reliable

Benefits:

  • Your website is literally made in the UK (your house)
  • Your hardware, your rules
  • You learn the stuff people pretend they learned

Cons:

  • Your website goes down when you move the Pi
  • Your website goes down when you accidentally unplug the Pi
  • Your website goes down when you get cocky

If You’re My Friend: Do This

If you’ve got a personal website, a Pi, and a mild desire to suffer for knowledge, I genuinely recommend this.

Start small:

  • Don’t migrate your whole life
  • Deploy something dumb first (a static page, a redirect, a tiny blog)
  • Get it working on LAN
  • Then add the Tunnel

And if you do it, message me.

I want a future where my friend group is just a bunch of lovely people casually running their websites on little computers in their living rooms.

That would be extremely stupid.

Which is how you know it’s the right idea.