09 July 2026

`apt` on a no-subscription Proxmox host: the 401, and installing around it

A quick one, because it bites everyone who runs Proxmox without a subscription and it's not obvious the first time. You go to install something, and apt falls over before it does anything useful:

E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/bookworm/InRelease  401  Unauthorized
E: The repository 'https://enterprise.proxmox.com/debian/pve bookworm InRelease' is not signed.

That's the enterprise repository, which needs a paid subscription key. Without one it returns 401, and because apt-get update exits non-zero, anything that runs update first — including a lot of vendor install scripts (curl ... | sh) — aborts before installing your actual package. Maddening, because the package you wanted may have nothing to do with Proxmox at all.

The quick way through (one package)

apt-get install uses the package lists already on disk; it doesn't insist on a fresh update. So if the lists for the repo you need were fetched at all, just install directly and let the enterprise 401 be someone else's problem:

DEBIAN_FRONTEND=noninteractive apt-get install -y <package>

That got a third-party tool onto a box for me even with the enterprise repo still throwing 401s in the background.

The proper fix (do this once)

If you're genuinely running no-subscription, switch to the no-subscription repo — that's the supported free channel, and crucially it's where pve-headers live (which you'll want the moment you build a DKMS module):

echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
  > /etc/apt/sources.list.d/pve-no-subscription.list
# and disable the enterprise list so update stops erroring:
# comment out the line in /etc/apt/sources.list.d/pve-enterprise.list
apt-get update
apt-get install -y pve-headers-$(uname -r)

(There's also a ceph enterprise list that 401s the same way if you use Ceph — same treatment.)

Notably, none of this requires turning off the enterprise repo to install around it in a pinch — but for a box you'll keep, sort the sources out properly so update is clean and DKMS rebuilds don't surprise you after a kernel bump.

I hope this saves you the head-scratching. Enjoy!

Clicking "I accept" on a captive portal — for a headless server

Captive portals are a fact of life on guest wifi: connect, get redirected to a page, tick a box or log in, and you're online. Fine for a laptop. A proper pain for a headless server that has no browser and no one sitting in front of it. Here's the trick I used to get a headless box past a per-MAC portal, and it generalises nicely.

Why you can't just do it from your laptop

The portal authorises per device — it remembers the MAC (and/or IP) that completed the login and lifts the walled garden for that client. So logging in from your laptop's browser authorises your laptop, not the server. The server's own connection is still walled. You have to make the login traffic originate from the server itself.

The trick: proxy a browser through the server

If you can SSH to the box (over a wired path, a second interface, whatever), turn it into a SOCKS proxy:

ssh -D 1080 -N user@server

Now anything you send into localhost:1080 is originated by the server and exits its network connection. Point a browser at that proxy and, from the portal's point of view, it's the server knocking — correct MAC, correct session. Chromium does remote DNS over SOCKS5, so name resolution goes out the server's link too.

I drove it headlessly with Playwright through the proxy:

chromium --proxy-server="socks5://localhost:1080"
# or, in Playwright, launch with proxy={"server":"socks5://localhost:1080"}

Navigate to anything over plain HTTP (http://neverssl.com is the classic), let the portal redirect fire, accept the terms / sign in, and the gateway authorises the server's MAC. Once that's done you can tear the proxy and browser down entirely — the authorisation lives at the gateway, keyed to the MAC, not in the browser session.

Making it stick

Two wrinkles for unattended use:

  • Sessions expire. Wrap the same headless flow in a small script and have a watchdog run it whenever a connectivity check (curl -s -o /dev/null -w '%{http_code}' http://connectivitycheck.gstatic.com/generate_204, want 204) comes back non-204. A persistent browser profile keeps any "remember me" cookie between runs.
  • Registration vs. login. Some portals (Sky's "The Cloud", BT Wi-fi and friends) want an account. A throwaway mailbox with an API — I used mail.tm — lets you register and, if needed, script the catching of a verification email.

It feels a bit like sawing through the bars from the inside, but it's entirely above board — you're authenticating the device that's actually using the connection. The SOCKS-proxy-through-the-box idea is the reusable part; keep it for the next headless thing stuck behind a portal.

I hope this resolves any difficulties you may be experiencing. Enjoy!

A Realtek RTL8821AU on kernel 6.8, and the `new_id` trap that hung my reboot

I plugged a USB wifi dongle into a headless Linux box and got... nothing. No wl interface, no driver bound. What followed was a proper little saga involving an out-of-tree driver, the wrong driver grabbing the device, and a reboot that wouldn't reboot. Here's the whole thing so you can skip the bits I didn't.

Identifying the chipset (and the usual gripe)

lsusb
... ID 2357:0120 TP-Link 802.11ac WLAN Adapter

USB 2357:0120 is a TP-Link Archer T2U Plus, chipset Realtek RTL8821AU. The product string just says "802.11ac WLAN Adapter", which is no help at all — and TP-Link cheerfully reuse that string and shuffle USB IDs across hardware revisions. (When will manufacturers learn not to make us reverse-engineer which silicon we actually bought?)

On kernel 6.8 there's no in-tree driver that binds it: rtl8xxxu lists a clutch of 2357:01xx IDs but not 0120, and rtw88 covers newer chips. So it's the out-of-tree DKMS driver.

Building the driver

morrownr's maintained fork does the job. On a Debian/Proxmox box you'll need the matching kernel headers and a toolchain first:

apt-get install -y dkms git build-essential
# headers matching `uname -r` (on a no-subscription Proxmox host, from the no-subscription repo)
git clone https://github.com/morrownr/8821au-20210708.git
cd 8821au-20210708 && ./install-driver.sh NoPrompt

DKMS reported rtl8821au/5.12.5.2 ... installed and the module loaded — but still no interface. That's where it got interesting.

The trap: the wrong driver had already grabbed it

While poking about earlier I'd modprobe'd one of the rtw88 USB drivers and added the device with a runtime new_id override. That driver had claimed the dongle — so the correct 8821au driver couldn't bind it, even though it was loaded. Worse, when I tried to unbind it:

echo -n "1-3:1.0" > /sys/bus/usb/drivers/rtw_8822bu/unbind   # <-- hung

The write blocked in uninterruptible sleep (D state). You cannot kill a D-state process; it's wedged in the kernel waiting on the driver's disconnect path. And here's the sting in the tail: that stuck process then hung the rebootsystemd-shutdown waited 90 seconds for it, failed to remount the root filesystem read-only ("Device or resource busy"), forced the reboot anyway, and I finished it with a hard power-cycle. (No harm done — ext4 journalled-recovered on the way back up.)

The fix that stuck

Blacklist the drivers that shouldn't touch it, so on boot only the right one matches:

# /etc/modprobe.d/wifi-dongle.conf
blacklist rtw88_8822bu
blacklist rtw88_8821cu
blacklist rtl8xxxu

Reboot. With the imposters out of the way, 8821au claimed the device and wlx<mac> appeared, type managed, ready for wpa_supplicant.

The lesson I'll carry: new_id overrides are runtime-only and brilliant for experimenting, but if one goes wrong, don't fight the live binding — a reboot clears the runtime state and a blacklist makes the result permanent. Trying to unbind a wedged USB driver by hand is how you end up power-cycling a server.

Happy prototyping!

Is your firewall man-in-the-middling you? Check the certificate issuer.

A VPN I was setting up simply refused to connect — no error I could act on, just a sulk. The thing that explained it took one command, and it's a trick worth having in your pocket whenever a service mysteriously won't talk on a network you don't control.

The one command

echo | openssl s_client -connect controlplane.example-vpn.com:443 -servername controlplane.example-vpn.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject

What came back was not the certificate I expected:

issuer=C=US, O=Fortinet, OU=Certificate Authority, CN=<appliance-serial>
subject=CN=controlplane.example-vpn.com

That O=Fortinet issuer is the smoking gun. A FortiGate firewall was doing deep SSL inspection — terminating the TLS, presenting its own certificate signed by the appliance's CA, and re-encrypting onwards. My client validates that endpoint's certificate against the public trust store, the Fortinet CA isn't in it, the handshake fails, and the whole thing quietly gives up. (The VPN's own logs, to its credit, even said the cert "looks like Fortinet equipment" — they ship a detector for exactly this.)

The genuinely useful bit: map what's tampered with

Interception is rarely applied to everything — it's expensive, and it breaks things. So check a handful of endpoints and compare issuers; the pattern tells you your firewall's policy:

Endpoint Issuer seen Verdict
controlplane.example-vpn.com Fortinet intercepted
api.cloudflare.com Google Trust Services clean
github.com Sectigo clean

In my case the interception was targeted — the VPN's control plane specifically, everything else untouched. That immediately reframed the problem: stop fighting the blocked thing, and pick a transport the firewall leaves alone (a Cloudflare tunnel egressed perfectly happily, real cert and all).

Why this matters beyond "my VPN broke"

If a corporate box is inspecting your TLS, it can read everything that crosses it that it has decrypted — which is the point of the appliance, but worth being clear-eyed about. The cert issuer is the quickest way to find out whether and where it's happening. A real public CA (Let's Encrypt, Google Trust Services, Sectigo, DigiCert) on the cert means end-to-end; your employer's name, or "Fortinet"/"Palo Alto"/"Zscaler", means the conversation is being opened in transit.

Go and openssl s_client a few of your own endpoints — you may be surprised who's signing them.

Ta ta for now, and I hope you found this helpful.

ARP resolves but ping doesn't — the asymmetric-routing trap, and a one-line fix

I had two machines plugged into the same switch and couldn't get from one to the other. No ping, no SSH, nothing — yet the cable was fine and the other box was very much alive. The symptom that cracked it is worth knowing, because it points straight at the cause.

The tell

Ping timed out, but the ARP entry resolved perfectly:

arp -n | grep 192.168.50.2
192.168.50.2   ether   04:d4:c4:xx:xx:xx   C   enx...

ARP is layer 2. If the MAC resolves, the wire and the switch are doing their job — the other machine has answered an ARP broadcast at some point. So a resolving ARP entry alongside dead ICMP/TCP tells you the problem is layer 3, and specifically the return path.

Proving it with tcpdump

Capture on the interface while pinging:

sudo tcpdump -i enx... -n host 192.168.50.2

What I saw, each time I pinged: my echo request arrived, and the other box immediately fired off an ARP request for its default gateway — and got no reply. It had received my packet and was trying to route the response back, but my source address (on a different subnet, via a /16 that happened to cover the target) wasn't on its local segment, so it needed a gateway. That gateway was dead. The reply never left the building.

This is asymmetric routing: the outbound packet gets there, the reply can't find its way home. ARP keeps working throughout because ARP is broadcast on the local segment and doesn't care about gateways — which is exactly why you get the confusing "MAC resolves but nothing else works" picture.

The one-line fix

Give your machine a second address on the target's own subnet. Now your packets are sourced from an address the target can reach directly over ARP — no gateway required for the reply.

sudo ip addr add 192.168.50.50/24 dev enx...
ping -c3 -I 192.168.50.50 192.168.50.2
ssh -b 192.168.50.50 root@192.168.50.2

-I (ping) and -b (ssh) force the new source address so the reply path stays local. Remove it again with sudo ip addr del 192.168.50.50/24 dev enx... when you're done.

One aside that cost me a few minutes: even after this, ICMP stayed firewalled on the target while TCP worked fine. So don't let a still-failing ping convince you it hasn't worked — try the actual port you care about. The real fix for the target, of course, is to give it a working default gateway; the source-address trick is the get-in-now workaround.

I hope this saves you the tcpdump session. Enjoy!

09-two-claude-accounts-one-machine

title: "Running Two Claude Accounts Side by Side on the Same Machine"
date: 2026-06-23
tags: [claude, ai, homelab, linux]


Running Two Claude Accounts Side by Side on the Same Machine

I have two Claude subscriptions — a personal one and a work one through the University of Salford. Anthropic's rate limits apply per account, so when one hits the wall mid-session (and it does, usually at the worst possible moment) I'd like to be able to switch to the other and carry on, ideally without losing the thread of what I was doing. This turned out to be straightforward, once I understood how Claude Code manages its authentication.

How Claude Code Picks an Account

Claude Code stores its OAuth token in ~/.claude/.credentials.json. Everything else — settings, memory, session history, installed plugins, custom slash commands — lives alongside it in ~/.claude/. The key insight is that the binary respects a CLAUDE_CONFIG_DIR environment variable: point it somewhere else and that becomes the active profile. The credentials file in that directory determines which account you're logged into.

So: two directories, one credential each, everything else shared. That's the whole trick.

The Setup

I created ~/.claude-work/ as the work account's config directory. A small script, ~/.local/bin/claude-work, handles the rest: it creates the directory, symlinks the account-agnostic config from ~/.claude into it, sets CLAUDE_CONFIG_DIR, and launches claude. The symlinked items are:

  • settings.json and settings.local.json — permissions, allowlists, preferences
  • projects/ — the full session history and memory store
  • plugins/ — installed plugins
  • CLAUDE.md — the user-level instructions
  • commands/ — custom slash commands
  • statusline-command.sh — the statusline helper

The one thing deliberately not symlinked is .credentials.json — that stays private to each profile. Everything else is shared. The result is that switching accounts costs nothing: same memory, same history, same environment. The only difference is whose API quota is being consumed.

On first run, the work directory has no credential, so Claude opens its /login OAuth flow. Sign in once as the work account and the token is cached; subsequent runs go straight in. (Worth noting: my work account is on a Claude Team plan, and the organisation has disabled Remote Control. That can't be changed locally — it's expected behaviour.)

Persistent Sessions with tmux

My other requirement was that sessions should survive terminal closures, the same way a remote SSH session to a server would. I added two shell functions to ~/.bashrc:

claude() {
  if [ -n "$TMUX" ] || [ ! -t 1 ]; then command claude "$@"; else tmux new-session -A -s personal "command claude"; fi
}
claude-work() {
  if [ -n "$TMUX" ] || [ ! -t 1 ]; then command claude-work "$@"; else tmux new-session -A -s work "command claude-work"; fi
}

Running claude from any terminal now attaches to (or creates) a persistent tmux session named personal; claude-work does the same for work. Detach with Ctrl-b d. Re-running the command from another terminal reattaches. The -A flag means you never accidentally create a second session when one is already running. To pass flags directly — say, a non-interactive -p prompt — command claude bypasses the wrapper.

Sharing MCP Servers

The one thing that can't be symlinked is ~/.claude.json. This file holds the active account identity and gets written constantly during a session; sharing it between two concurrently running instances would be asking for corruption. Unfortunately it's also where project-scoped MCP servers are configured.

The solution is Claude's project-level .mcp.json file. Drop one in the working directory and it's loaded by both profiles automatically:

{ "mcpServers": { "cloudflare": { "type": "http", "url": "https://mcp.cloudflare.com/mcp" } } }

The claude.ai integrations (Gmail, Drive, Calendar, etc.) are account-connected — they arrive automatically with whichever account is logged in, no extra configuration needed.

Switching After a Rate Limit

In practice the workflow is: hit the rate-limit message, open a new terminal, type claude-work. The tmux session picks up and I'm in the work profile. From there, /resume shows the full session history — because projects/ is symlinked, both profiles read from the same store. I can pick up the conversation exactly where I left off, just on a different quota.

When the personal limit clears I switch back. The work session stays attached in its tmux window; nothing is lost on either side.

The Knowledgebase

Separately, I've been thinking about a knowledgebase — a collection of PDFs, images, and reference documents that Claude sessions can draw on. The numbers involved (low gigabytes, lots of files around 500KB each) are well within what Git LFS handles comfortably. The Gitea instance I run (CT116 on Proxmox) already has LFS support and I've grown its disk from 8GB to 32GB to give it comfortable headroom.

The files themselves will live on a TrueNAS NFS share (/mnt/knowledgebase, ZFS with LZ4 compression and 128K record size — well-suited to small files), already mounted on the laptop. Version history in Gitea, actual bytes on TrueNAS. It's empty for now but the plumbing is in place.

Is Any of This Novel?

Probably not — but I couldn't find it documented clearly anywhere, so perhaps this is useful to someone. The CLAUDE_CONFIG_DIR variable isn't prominently advertised; I found it by looking at what the binary actually checks at startup. The symlink approach for sharing config while keeping credentials separate feels like the right level of hackery: low-tech, transparent, and easy to undo.

Fare thee well and I hope you find this helpful.

Fine-tuning LLMs locally with Unsloth Studio — NFS storage, boot-on-start, and a self-inflicted security hole

I've wanted a proper local fine-tuning setup for a while. Cloud GPUs are fine until the bill arrives, and there's something satisfying about training a model on hardware sitting in the next room. So when a spare box on one of my Proxmox nodes ended up with four RTX 5060 Ti 16GB cards in it, I installed Unsloth Studio — the web UI from Unsloth (the open-source fine-tuning project built by Australian brothers Daniel and Michael Han — see their about page) — and set about making it behave the way I wanted.

The aim:

  • Unsloth Studio running in an unprivileged LXC container on Proxmox (container 117, the four GPUs passed through).
  • Its HuggingFace model cache pointed at my TrueNAS NFS share — the container only has a 64GB root disk, and a single bnb-4bit model is 6–13GB, so that fills up fast.
  • The studio starting on boot (it's a thing you launch by hand from a shell, which is no good for a box you want to just work).
  • Reachable at https://unsloth.mattsouthgate.co.uk, behind the same Cloudflare Access gate as the rest of my home lab.

It mostly went well. A couple of things bit back, and one of them — entirely my own doing — was a security hole I'll come to. Here's the whole thing, dead ends included.

Where the models live

The setting in the UI is Storage → Models Folder, and out of the box it points at /root/.cache/huggingface/hub — the standard HuggingFace hub cache. That's what I wanted on the NAS.

My first instinct was to mount the NFS share inside the container. Unfortunately that doesn't work in an unprivileged LXC:

# inside the container
mount -t nfs -o ro 10.140.3.200:/mnt/zrust/ml-models /mnt/nfs-test
mount: /mnt/nfs-test: permission denied.

The kernel blocks NFS mounts inside a user namespace, even with AppArmor unconfined. I briefly considered converting the container to privileged — which would let it mount NFS — but in a privileged container, container-root is host-root, and this is a box that downloads and runs arbitrary model code from the internet. That's exactly the boundary an unprivileged container exists to keep. Not worth it.

The right way is the boring way: mount the share on the Proxmox host, then bind-mount it into the container. The host already had it in /etc/fstab, so I only needed the bind and to set the container to start on boot:

# on the Proxmox host
pct set 117 -mp0 /mnt/ml-models,mp=/mnt/ml-models
pct set 117 -onboot 1
pct reboot 117

The unprivileged ownership dance

Here's the bit that catches people. An unprivileged LXC maps container-root (uid 0) to host uid 100000 (this is documented in the Proxmox container docs). A directory created on the share as host-root shows up as nobody inside the container, and the container can't write to it. So the cache directory has to be owned by 100000 on the host:

# on the host
mkdir -p /mnt/ml-models/llm/hf-cache /mnt/ml-models/llm/hf-xet
chown 100000:100000 /mnt/ml-models/llm/hf-cache /mnt/ml-models/llm/hf-xet

That chown only works if the NFS export isn't root-squashed — mine is no_root_squash, so it took. A check from inside the container confirmed it had landed:

# inside the container, post-reboot
mount | grep ml-models
# 10.140.3.200:/mnt/zrust/ml-models on /mnt/ml-models type nfs (rw,...)

echo hi > /mnt/ml-models/llm/hf-cache/.wtest && echo WRITE-OK
# WRITE-OK
ls -lan /mnt/ml-models/llm/ | grep hf-cache
# drwxr-xr-x 2 0 0 ... hf-cache    <- uid 0 (root) inside; 100000 on the host

What the host sees as 100000 the container sees as 0. Symlinks work over the mount too, which matters — the HuggingFace cache is full of them (snapshots are symlinks into a blobs store).

Telling the studio (and only the studio) to use it

I dug through the Unsloth Studio source to see how it decides where the cache goes. The relevant function is _setup_cache_env() in backend/utils/paths/storage_roots.py, and the key line is this:

for key, value in defaults.items():
    if key not in os.environ:
        os.environ[key] = value

It only sets the cache variables if you haven't already. So explicit environment variables win. That let me do something neat: point the hub cache at the NAS while leaving HF_HOME at its local default — which keeps the auth token ($HF_HOME/token) on local disk and off the shared NAS, where it has no business being.

HF_HUB_CACHE=/mnt/ml-models/llm/hf-cache
HF_XET_CACHE=/mnt/ml-models/llm/hf-xet
# HF_HOME left alone -> token stays at /root/.cache/huggingface/token

A trap worth flagging: that same function does a best-effort mkdir on the cache path. If you start the studio with the cache pointed at /mnt/ml-models/... before the bind-mount exists, it'll cheerfully create that directory on the local disk and then shadow the mount when it appears. Mount first, start second.

The token, which did not go quietly

Logging the HuggingFace token in should have been one command. It wasn't:

hf auth login --token hf_xxxx…
...
ValueError: Token claude-hub not found in /root/.cache/huggingface/stored_tokens

The newer hf CLI (from the huggingface_hub library) stores tokens under a name in a stored_tokens file and then tries to "activate" them — and that machinery tripped over itself, writing an empty entry and declaring the token missing. Rather than fight it, I wrote the plain token file directly, which huggingface_hub reads perfectly well:

printf '%s' 'hf_xxxx…' > /root/.cache/huggingface/token
chmod 600 /root/.cache/huggingface/token
hf auth whoami
# user: IdeasFixer

whoami hits the API, so that's the token genuinely validated, not merely written.

Starting on boot

Unsloth Studio is launched by hand (unsloth studio -H 0.0.0.0 -p 8888) — no service file. For a box I want to just work, that won't do, so it gets a systemd unit. The cache variables go in Environment= lines, because systemd doesn't read your shell profile:

# /etc/systemd/system/unsloth-studio.service
[Unit]
Description=Unsloth Studio (LAN on :8888, HF cache on TrueNAS NFS)
After=network-online.target
Wants=network-online.target
RequiresMountsFor=/mnt/ml-models

[Service]
Type=simple
User=root
Environment=HF_HUB_CACHE=/mnt/ml-models/llm/hf-cache
Environment=HF_XET_CACHE=/mnt/ml-models/llm/hf-xet
ExecStartPre=/usr/bin/test -d /mnt/ml-models/llm/hf-cache
ExecStart=/root/.local/bin/unsloth studio -H 0.0.0.0 -p 8888 --no-cloudflare
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

(That --no-cloudflare flag is important — more on why in a moment.) onboot: 1 on the container handles the other half. I tested the whole chain by rebooting the container and watching it come back unattended:

Check Result
Container running after reboot
NFS bind-mount present
unsloth-studio auto-started + enabled ✅ active / enabled
Listening on :8888
HF_HUB_CACHE resolves to NFS, models present ✅ 3 models

A tidy hostname, via the existing Caddy

I didn't want a fresh tunnel for this. I already run Caddy (thanks to Matt Holt and the Caddy authors) as a reverse proxy behind my main Cloudflare Tunnel, fronting every other *.mattsouthgate.co.uk service. Adding one more is three small steps.

A Caddy vhost — and mind the http:// prefix, because the tunnel delivers on plain port 80 and a bare hostname triggers Caddy's automatic HTTPS, which sends a 308 redirect that loops straight back through the tunnel (ask me how I know):

# /etc/caddy/conf.d/unsloth.mattsouthgate.co.uk.caddy
http://unsloth.mattsouthgate.co.uk {
    reverse_proxy http://10.140.0.61:8888
}

Then the tunnel ingress (unsloth.mattsouthgate.co.uk → http://10.140.3.156, the Caddy box) and a proxied DNS CNAME to the tunnel. A curl confirmed it both works and is gated:

curl -sI https://unsloth.mattsouthgate.co.uk/
# HTTP/2 302
# location: https://mattsouthgate.cloudflareaccess.com/cdn-cgi/access/login/...

That 302 to the Cloudflare Access login is exactly what I wanted. Anyone reaching the studio has to pass Access and the studio's own password. Which brings me to the bit I got wrong.

The hole I dug for myself

While auditing what I'd built, I noticed something I should have caught immediately. Unsloth Studio, very helpfully, auto-creates its own free Cloudflare tunnel on startup so you can share it without any setup. Lovely feature. Except it means that alongside my carefully Access-gated hostname, the studio was also publishing a second, public *.trycloudflare.com URL — with no Access gate in front of it. The only thing standing between the open internet and a box with four GPUs and my HuggingFace token was the studio's login password (which, for the record, is a deliberately throwaway "get it working first" password I hadn't yet replaced — so, not much).

There it was in the process list and the logs:

ps aux | grep cloudflared
# /root/.unsloth/studio/bin/cloudflared tunnel --url http://localhost:8888 --no-autoupdate
journalctl -u unsloth-studio | grep trycloudflare
# Unsloth Studio running on https://wine-graduates-evaluated-herbal.trycloudflare.com

The fix is one flag — --no-cloudflare — which I'd already slipped into the unit file above. After a restart, the ungated door is gone:

# the old public URL — now dead
curl -s -o /dev/null -w "%{http_code}\n" https://wine-graduates-evaluated-herbal.trycloudflare.com/
# 530      (Cloudflare: origin tunnel is gone)

# my gated hostname — still serving
curl -s -o /dev/null -w "%{http_code}\n" https://unsloth.mattsouthgate.co.uk/
# 302      (redirect to Cloudflare Access login)

# and no tunnel process left
pgrep -a cloudflared
# NO cloudflared process — confirmed

Lesson, cheerfully relearned: when a tool offers to expose itself to the internet "for free and with zero config", check whether it's still doing that after you've built your own properly-gated route. A fuller security audit of the whole setup is a follow-up post of its own.

Which models?

These are 16GB cards, and Unsloth's open-source path fine-tunes on a single GPU per run (four cards means four parallel jobs, not one big 64GB job). For QLoRA — the 4-bit fine-tuning method from Tim Dettmers and colleagues, arXiv:2305.14314, which underpins the bnb-4bit format via the bitsandbytes library — that comfortably fits up to about 14B in 4-bit; 24B and up don't. So I pulled the Unsloth bnb-4bit dynamic quants (the training-ready format, not the GGUFs, which are for llama.cpp inference):

Model Base model author Size on disk
unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit Meta 6.0 GB
unsloth/phi-4-unsloth-bnb-4bit (14B) Microsoft 10.4 GB
unsloth/gemma-3-12b-it-unsloth-bnb-4bit Google DeepMind 12.8 GB
unsloth/gemma-3-4b-it-unsloth-bnb-4bit Google DeepMind 4.6 GB

gemma-3-27b-class and Mistral-Small-3.2-24B (14.6GB) were left out — their 4-bit weights alone meet or exceed 16GB before you've fit a single activation.

The proof it all hangs together — a fresh download lands on the NAS and not the local disk:

hf download hf-internal-testing/tiny-random-bert
ls -d /mnt/ml-models/llm/hf-cache/models--hf-internal-testing--tiny-random-bert  # present
ls -d /root/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-bert # absent

And migrating the pre-existing 21GB cache off the root filesystem freed exactly what you'd hope:

# df -h /  (container root)
# before: 46G used / 63G  (77%)
# after:  26G used / 63G  (43%)

Summary

  • Unsloth Studio fine-tunes locally on four 16GB GPUs in an unprivileged Proxmox LXC.
  • Models live on TrueNAS over NFS (host mount + bind-mount + a chown 100000 so the unprivileged container can write); the auth token stays on local disk.
  • It starts on boot via systemd, verified across a full reboot.
  • It's reachable at a clean HTTPS hostname through the existing Caddy + Cloudflare setup, gated by Cloudflare Access — and the studio's own ungated tunnel is now disabled.

Future work

  • Replace the placeholder passwordpass was only ever a get-it-running stand-in; it wants a real one now the routing is sorted.
  • Move the fine-tuned outputs to the NAS too. I redirected the download cache, but the outputs/exports/datasets directories are still on the local disk. They want symlinking onto the share — carefully, because the studio's SQLite databases (studio.db, auth.db) must stay local (SQLite and NFS don't get along).
  • Back up those config databases, which currently live only on the container's root disk.
  • A proper security audit of the whole home-lab edge — the trycloudflare surprise above earned it. That's the next post.

References and credits

With thanks to the people whose work this is built on:

I hope this saves someone the permission-denied head-scratching — and prompts you to check whether your tools have quietly opened a door you didn't ask for. Enjoy!

Matt

Getting 4× RTX 5060 Ti GPUs Working on Proxmox VE 9 (ASUS X99-E WS with PLX PCIe Switch)

Getting 4× RTX 5060 Ti GPUs Working on Proxmox VE 9 (ASUS X99-E WS with PLX PCIe Switch)

If you've tried to run NVIDIA Blackwell GPUs on an older workstation board with a PLX PCIe switch and hit cryptic GSP firmware failures — this post documents what went wrong and exactly how we fixed it.


The Setup

  • Host: Proxmox VE 9.1, kernel 6.14.11-7-pve
  • Board: ASUS X99-E WS — an older Intel LGA2011-v3 workstation board with 2× PLX PEX 8747 PCIe switch chips, enabling 4-way x16/x16/x16/x16 GPU slots
  • GPUs: 4× ASUS RTX 5060 Ti 16GB (Blackwell, GB206, PCI ID 10de:2d04)
  • Goal: Run all 4 GPUs for vLLM inference in an LXC container (64GB total VRAM)

The Problem

After installing the NVIDIA open kernel modules (required — the proprietary driver doesn't support GB206), nvidia-smi refused to show any GPU. dmesg showed the key error:

NVRM: _kgspBootGspRm: unexpected WPR2 already up, cannot proceed with booting GSP
NVRM: RmInitAdapter: Cannot initialize GSP firmware RM
NVRM: Xid (PCI:0000:06:00): 79, GPU has fallen off the bus.

And attempting modprobe nvidia caused a full kernel panic:

KERNEL PANIC! Please reboot your computer.
Timeout: Not all CPUs entered broadcast exception handler

Three sessions of diagnosis later, we had it fully working. Here's what we found.


Understanding GSP Firmware

Modern NVIDIA GPUs (Ampere onwards) use a GSP (GPU System Processor) — an ARM microcontroller inside the GPU that runs its own firmware. The CPU-side NVIDIA driver is a thin shim; all real GPU initialisation happens inside the GSP. When the driver loads, it bootstraps the GSP by uploading firmware and waiting for GSP_INIT_DONE.

The key implication: if GSP initialisation fails, the driver reports the GPU as lost. And anything that interferes with the DMA operations during that bootstrap will cause GSP to fail.


Root Cause 1: WPR2 Persistence

WPR2 (Write Protected Region 2) is a region of GPU SRAM that the GSP firmware locks during its bootstrap sequence. Critical properties:

  • WPR2 is locked when GSP begins running
  • It persists across warm reboots — the GPU stays powered via the ATX 12V rail
  • If GSP fails after locking WPR2, every subsequent driver load finds WPR2 already locked and refuses to proceed
  • Only cutting 12V power (a true cold cycle) clears WPR2

The root cause of our "WPR2 already up" loop: the nvidia module was baked into the initramfs and loading on every boot. It would attempt GSP init, fail, leave WPR2 locked, and every subsequent manual modprobe nvidia would hit the same locked state.

Fix: blacklist nvidia in /etc/modprobe.d/nvidia-blacklist.conf and rebuild initramfs:

cat > /etc/modprobe.d/nvidia-blacklist.conf << 'EOF'
blacklist nvidia
blacklist nvidia_drm
blacklist nvidia_modeset
blacklist nvidia_uvm
EOF
update-initramfs -u -k $(uname -r)

The update-initramfs step is essential — a modprobe.d blacklist alone doesn't affect modules already embedded in the initramfs image.


Root Cause 2: PCIe Completion Timeout Through PLX Switch

With WPR2 accumulation fixed, the actual GSP initialisation failure became visible. The culprit: PCIe completion timeout.

When the NVIDIA driver bootstraps GSP, the GSP ARM processor performs DMA transfers and RPC roundtrips. These operations take time — and when routed through a PLX PEX 8747 PCIe switch, they take more time than the default PCIe completion timeout (~50ms on X99 root ports).

When the timeout expires, the CPU-side driver marks the GPU as unreachable:
- CmpltTO+ flag in the GPU's PCIe AER status register
- NV_ERR_GPU_IS_LOST (0x0000000F) returned from RPC poll
- Xid 79 (GPU fallen off bus)

This was confirmed by lspci -vv on all GPUs showing UESta: CmpltTO+ after every failed init attempt.

Fix: disable PCIe completion timeout on all devices in the path — root ports, PLX bridges, and GPUs — by setting bit 4 of the PCIe Device Control 2 register (CAP_EXP+0x28):

for dev in 00:02.0 00:03.0 03:00.0 07:00.0 04:08.0 04:10.0 08:08.0 08:10.0 05:00.0 06:00.0 09:00.0 0a:00.0; do
    cur=$(setpci -s $dev CAP_EXP+0x28.w 2>/dev/null)
    [ -n "$cur" ] && setpci -s $dev CAP_EXP+0x28.w=$(printf '%04x' $((0x$cur | 0x0010)))
done

Devices in the chain:
- 00:02.0, 00:03.0 — X99 root ports
- 03:00.0, 07:00.0 — PLX PEX 8747 upstream ports
- 04:08.0, 04:10.0, 08:08.0, 08:10.0 — PLX downstream ports
- 05:00.0, 06:00.0, 09:00.0, 0a:00.0 — the 4 RTX 5060 Ti GPUs

After applying this, modprobe nvidia succeeded and all GPUs appeared in nvidia-smi.


Root Cause 3: Kernel Panic from PCIe AER → MCE Escalation

Without the completion timeout fix, the GSP failure caused a chain reaction:

  1. GSP DMA timeout → PCIe uncorrectable error
  2. Linux AER handler → escalates to Machine Check Exception
  3. MCE handler broadcasts NMI to all CPUs
  4. Some CPUs don't respond → kernel panic: "Not all CPUs entered broadcast exception handler"

Fix: add pci=noaer to kernel cmdline. This prevents AER from escalating to MCE, allowing the driver to fail gracefully instead of panicking the kernel.


Why FLR and SBR Don't Work Through PLX Switches

We tried the obvious software approaches to clear WPR2 between reboots:

  • PCIe FLR (Function Level Reset): echo 1 > /sys/bus/pci/devices/.../reset — does not propagate PERST# through the PLX PEX 8747. WPR2 remains.
  • Secondary Bus Reset (SBR): toggling bit 6 of BRIDGE_CONTROL via setpci — the PLX switch absorbs the reset signal rather than forwarding PERST# downstream. GPUs disappear from lspci during reset and re-enumerate, but WPR2 is still locked when the driver loads.

The PLX 8747 is designed for PCIe topology expansion, not to forward physical reset signals. Only a true cold power cycle (cutting ATX 12V) reliably clears WPR2.


The Bonus Issue: KVM on HDMI Causes Xid 119

After the PCIe fix, 3 of 4 GPUs came up cleanly. GPU 1 showed:

|   1  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:06:00.0 N/A | ERR! ERR! ERR!  N/A/N/A |

This was not a boot failure. dmesg showed Xid 119 — a GSP RPC timeout during normal operation triggered when nvidia-smi queried the display subsystem. Root cause: a KVM switch was connected to that GPU's HDMI port. The GPU initialised fine but then got stuck on a display-related RPC call.

Fix: unplug the KVM HDMI cable from that GPU. After a cold cycle, all 4 GPUs came up clean.


Making It Persistent: systemd Service

The setpci changes are volatile — lost on every reboot. We created a systemd oneshot service to apply the fix and load the driver on every boot:

/usr/local/bin/nvidia-pcie-fix.sh:

#!/bin/bash
for dev in 00:02.0 00:03.0 03:00.0 07:00.0 04:08.0 04:10.0 08:08.0 08:10.0 05:00.0 06:00.0 09:00.0 0a:00.0; do
    cur=$(setpci -s $dev CAP_EXP+0x28.w 2>/dev/null)
    [ -n "$cur" ] && setpci -s $dev CAP_EXP+0x28.w=$(printf '%04x' $((0x$cur | 0x0010)))
done
modprobe nvidia

/etc/systemd/system/nvidia-pcie-fix.service:

[Unit]
Description=Disable PCIe completion timeout and load NVIDIA driver
After=sysinit.target systemd-udev-settle.service
Before=nvidia-persistenced.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/nvidia-pcie-fix.sh

[Install]
WantedBy=multi-user.target
systemctl enable nvidia-pcie-fix.service

Final Working State

| NVIDIA-SMI 575.64.05    Driver Version: 575.64.05    CUDA Version: 12.9     |
|   0  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:05:00.0 Off |   0%  32C  P0  19W/180W |   0MiB/16311MiB |
|   1  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:06:00.0 Off |   0%  33C  P0  20W/180W |   0MiB/16311MiB |
|   2  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:09:00.0 Off |   0%  27C  P0  21W/180W |   0MiB/16311MiB |
|   3  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:0A:00.0 Off |   0%  29C  P0  26W/180W |   0MiB/16311MiB |

64GB VRAM, all healthy.


Complete Working Configuration

Kernel cmdline (/etc/default/grub):

GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on pcie_aspm=off pci=realloc,noaer"

/etc/modprobe.d/nvidia.conf:

softdep nouveau pre: nvidia
options nvidia NVreg_EnablePCIeGen3=1 NVreg_EnableGpuFirmwareLogs=1 NVreg_RegistryDwords=RmGspBootRetryAttempts=1;RMPcieFlrDevinitTimeout=4

Note: NVreg_RegistryDwords requires semicolons between key=value pairs — spaces silently fail.

/etc/modprobe.d/nvidia-blacklist.conf:

blacklist nvidia
blacklist nvidia_drm
blacklist nvidia_modeset
blacklist nvidia_uvm

Followed by update-initramfs -u -k $(uname -r).

Driver: NVIDIA open kernel modules 575.64.05 (earliest GB206-capable version available at time of writing).


Summary: What to Do If You Hit This

  1. If you see unexpected WPR2 already up: blacklist nvidia in initramfs, do a cold power cycle (12V off for 30s), then load the driver manually.
  2. If you see Xid 79 with CmpltTO+ in lspci -vv: disable PCIe completion timeout on the full root-to-GPU path via setpci CAP_EXP+0x28.
  3. If loading nvidia causes a kernel panic: add pci=noaer to the kernel cmdline.
  4. If one GPU shows ERR! in nvidia-smi: check for a display cable (HDMI/DP) connected to that GPU and disconnect it.
  5. Never do rmmod nvidia + modprobe nvidia without a cold cycle in between — rmmod re-locks WPR2.