<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Charlie Seay</title><description>Self-hosting, local AI, Docker, and building things that run on your own hardware.</description><link>https://charlieseay.com/</link><item><title>StdOut v1.0: A Dead-Simple Container Log Viewer for Self-Hosters</title><link>https://charlieseay.com/blog/stdout-v10-a-dead-simple-container-log-viewer-for-self-hosters/</link><guid isPermaLink="true">https://charlieseay.com/blog/stdout-v10-a-dead-simple-container-log-viewer-for-self-hosters/</guid><description>I run a homelab. Docker Compose stacks, n8n workflows, MCP servers, the usual self-hosted chaos. And every time something breaks, I&apos;m SSH&apos;d into my Mac Mini, running docker logs containername | grep e</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>## The Problem: Container Logs Shouldn&apos;t Require a PhD

I run a homelab. Docker Compose stacks, n8n workflows, MCP servers, the usual self-hosted chaos. And every time something breaks, I&apos;m SSH&apos;d into my Mac Mini, running `docker logs container_name | grep error` like it&apos;s 1997.

Meanwhile, enterprise ops teams get Datadog and Splunk. Self-hosters get... the terminal. Or worse, bloated monitoring stacks that need their own monitoring.

I wanted something in between: a lightweight web UI that shows me container logs without requiring Elasticsearch, Prometheus, and a degree in YAML archaeology.

So I built [StdOut](https://seayniclabs.com).

## What StdOut Actually Does

StdOut is a single-container log viewer for Docker environments. It does three things:

1. **Lists your running containers** — all of them, with status and uptime
2. **Streams logs in real-time** — pick a container, see stdout/stderr as it happens
3. **Searches logs** — grep-style filtering without touching the command line

That&apos;s it. No metrics. No alerts. No Grafana dashboards. Just logs, fast.

It mounts your Docker socket (read-only), exposes a web interface, and gets out of your way. If you&apos;re already running Portainer or Dockge, think of this as the log tab you actually want to use.

## Why I Didn&apos;t Just Use [Insert Tool Here]

**Portainer?** Great for container management. Terrible for reading logs. The UI truncates output, scrolling is janky, and searching means exporting to a file.

**Dozzle?** Closer. But it tries to be more than a log viewer — badges, filters, multi-host setups. I wanted something leaner.

**Dockge?** Excellent for managing Compose stacks. Still not a log-focused tool.

**Loki + Promtail + Grafana?** Overkill. You&apos;re configuring retention policies before you&apos;ve fixed the thing that&apos;s actually broken.

I wanted a tool that fits between &quot;SSH and grep&quot; and &quot;enterprise observability stack.&quot; StdOut is that tool.

## How It Works (The Technical Bits)

StdOut is a Go binary that:

- Connects to Docker&apos;s API via `/var/run/docker.sock`
- Streams container logs using the Docker SDK&apos;s log-following API
- Serves a single-page web UI built with vanilla HTML/CSS/JS
- Keeps no state — your logs stay in Docker, where they belong

The container itself is 15MB. No database. No Redis. No Node.js runtime inflating the image to 200MB.

You deploy it with one Docker Compose block:

```yaml
services:
  stdout:
    image: seayniclabs/stdout:latest
    container_name: stdout
    ports:
      - &quot;8080:8080&quot;
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: unless-stopped
```

That&apos;s it. No environment variables. No config files. It just works.

If you&apos;re paranoid about socket access (you should be), run it in a network with only the containers you want to monitor. Or put it behind Cloudflare Zero Trust and lock it to your tailnet.

## What I&apos;m Using It For

Every morning, I check my n8n automations. Some workflows fail silently. With StdOut, I open a browser tab, filter for `error`, and know in 10 seconds if something&apos;s broken.

When I&apos;m testing MCP servers, I run them in Docker and watch the logs in real-time. No switching between terminal tabs. No losing scroll position.

When a friend asks &quot;why is my Pi-hole acting weird,&quot; I send them StdOut. They install it, see the DNS query logs, and fix it themselves.

It&apos;s not for everyone. If you&apos;re running a hundred containers, you need something heavier. But if you&apos;re a self-hoster with 10-20 services, this is the tool you didn&apos;t know you needed.

## Where It&apos;s Going

Version 1.0 is intentionally minimal. It does one thing well. But there are obvious next steps:

- **Log retention**: search historical logs without needing a separate logging driver
- **Multi-host support**: manage logs from multiple Docker hosts in one UI
- **Webhooks**: trigger alerts when certain log patterns appear
- **Docker Compose integration**: show logs for all services in a stack, grouped

I&apos;m building this in public. If you self-host, give it a try. If it saves you one SSH session, it&apos;s done its job.

## Try It Now

StdOut is free, open-source, and available at [seayniclabs.com](https://seayniclabs.com).

If you&apos;re on r/selfhosted or Hacker News and thinking &quot;I would&apos;ve built this differently&quot; — good. Fork it. Send a PR. Or just tell me what&apos;s broken.

The point isn&apos;t to replace your entire observability stack. The point is to make logs suck less for people who just want to ship side projects without becoming SREs.

Now go deploy it. Your future self will thank you.</content:encoded><category>technology</category><category>personal</category><category>ai</category></item><item><title>From Direct Classification to Agentic Routing: Local vs Cloud AI</title><link>https://charlieseay.com/blog/from-direct-classification-to-agentic-routing-local-vs-cloud-ai/</link><guid isPermaLink="true">https://charlieseay.com/blog/from-direct-classification-to-agentic-routing-local-vs-cloud-ai/</guid><description>You&apos;re building AI workflows. Maybe it&apos;s a support ticket classifier, a document parser, or an MCP server that routes queries to the right data source. The question hits immediately: do I run this loc</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>## The Problem: Every AI Request Isn&apos;t Equal

You&apos;re building AI workflows. Maybe it&apos;s a support ticket classifier, a document parser, or an MCP server that routes queries to the right data source. The question hits immediately: do I run this locally or send it to Azure/OpenAI?

Most people pick one and stick with it. Local for privacy, cloud for power. But that&apos;s leaving performance and cost on the table. The real answer is *both*, routed intelligently.

I&apos;ve run into this building MCP servers for SeaynicNet. Some queries need GPT-4&apos;s reasoning. Others are trivial pattern matching that a local 7B model handles in milliseconds. The trick is knowing which is which *before* you make the expensive call.

## Direct Classification: The Naive Approach

The simplest pattern: every request hits your cloud LLM. Azure OpenAI gets a ticket description, returns a category. Done.

```python
response = azure_client.chat.completions.create(
    model=&quot;gpt-4&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Classify this ticket: {text}&quot;}]
)
category = response.choices[0].message.content
```

This works. It&apos;s also wasteful. You&apos;re paying $0.03/1K tokens for queries like &quot;reset my password&quot; that a regex could handle. At scale, that&apos;s real money. More importantly, it&apos;s slow — every request waits for a network round-trip.

## Local Models: Fast and Cheap, But Limited

Flip it: run everything through a local model. Llama 3.1 8B on a Mac Mini M4 Pro handles basic classification at ~50 tokens/second with zero API cost.

```python
from llama_cpp import Llama

model = Llama(model_path=&quot;./models/llama-3.1-8b.gguf&quot;, n_ctx=4096)
response = model.create_chat_completion(
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Classify: {text}&quot;}]
)
```

Latency drops to milliseconds. Cost drops to electricity. But now you&apos;re stuck: the local model chokes on nuanced queries. &quot;My login works on mobile but not desktop, only after VPN connects&quot; needs reasoning power that 8B parameters don&apos;t reliably deliver.

## Agentic Routing: The Hybrid Pattern

Here&apos;s the move: use a cheap local model as a *router*. It doesn&apos;t classify the ticket — it decides *which system* should classify it.

```python
router_prompt = f&quot;&quot;&quot;
Analyze this query and return ONLY &apos;local&apos; or &apos;cloud&apos;:
- local: simple, common patterns (password reset, account locked, basic how-to)
- cloud: complex reasoning, edge cases, ambiguous intent

Query: {text}
&quot;&quot;&quot;

route = local_model(router_prompt).strip().lower()

if route == &quot;local&quot;:
    result = local_classifier(text)
else:
    result = azure_classifier(text)
```

The router runs locally, completes in ~100ms, and costs nothing. It catches 70-80% of requests — the easy wins. The remaining 20-30% that genuinely need GPT-4&apos;s horsepower hit Azure.

Result: lower cost, better latency for most requests, full power when you need it.

## When This Actually Matters

This isn&apos;t academic. I&apos;ve seen enterprise support systems process 10K+ tickets daily. Direct classification at $0.03/1K tokens with average 500-token exchanges = $150/day, $4500/month. Agentic routing cuts that to ~$1000/month while improving p50 latency.

For MCP servers, it&apos;s the difference between a chatbot that feels snappy vs one that makes you wait. If you&apos;re routing to different knowledge bases or APIs based on query intent, the router pattern lets you keep complex orchestration local and only call out when truly necessary.

## The Enterprise Reality Check

Running local models in production isn&apos;t trivial. You need:
- **Inference infrastructure**: Ollama, llama.cpp, or vLLM for serving
- **Model management**: versioning, updates, A/B testing
- **Fallback logic**: what happens when the router is wrong?

Azure OpenAI gives you SLAs, compliance, audit logs. Local gives you control and cost efficiency. The hybrid approach acknowledges both are valuable.

In a healthcare environment like mine, it also maps to data sensitivity. PHI-adjacent queries route locally by policy. General knowledge queries can hit Azure. The router becomes an enforcement point.

## Implementation Notes

A few things I&apos;ve learned:

1. **Router confidence matters**: If the local model returns `cloud` 40% of the time, it&apos;s not saving you much. Tune your prompt or fine-tune the router.

2. **Cache cloud responses**: If 5% of queries are identical, don&apos;t re-call Azure. Local Redis cache in front of the cloud route pays off fast.

3. **Monitor route decisions**: Log every `local` vs `cloud` choice. You&apos;ll find patterns — maybe all queries with specific keywords should skip routing entirely.

4. **Local doesn&apos;t mean offline**: I run my router model via Ollama on the same Docker network as the MCP server. No external calls, but also not single-process complexity.

## When to Skip This

If you&apos;re processing &lt;1000 requests/day, direct cloud classification is fine. The cost is noise. The complexity isn&apos;t worth it.

If your queries are genuinely all complex (legal document analysis, medical coding), there&apos;s no easy 80% to route locally. Just use the big model.

But if you&apos;re in that middle ground — enough volume to care about cost, enough variety to benefit from routing — the agentic pattern is worth building.

## Takeaway

Start with direct classification to prove the workflow. When you understand your query distribution, add a local router. Monitor the split. Tune until 70%+ stay local. That&apos;s when hybrid architecture stops being clever and starts being cost-effective.

The future of enterprise AI isn&apos;t local *or* cloud. It&apos;s local *and* cloud, with smart routing deciding which handle each request.</content:encoded><category>technology</category><category>personal</category><category>ai</category></item><item><title>How Enchapter Turns Reading Into an Adventure (Not a Chore)</title><link>https://charlieseay.com/blog/how-enchapter-turns-reading-into-an-adventure-not-a-chore/</link><guid isPermaLink="true">https://charlieseay.com/blog/how-enchapter-turns-reading-into-an-adventure-not-a-chore/</guid><description>My kid used to treat bedtime reading like a negotiation. &quot;Just one more page&quot; meant &quot;I&apos;m already planning my escape.&quot; The problem wasn&apos;t the books — we had a shelf full of great ones. The problem was </description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>## The Bedtime Battle I Finally Won

My kid used to treat bedtime reading like a negotiation. &quot;Just one more page&quot; meant &quot;I&apos;m already planning my escape.&quot; The problem wasn&apos;t the books — we had a shelf full of great ones. The problem was that reading felt like a chore, something we did because we were supposed to, not because it was rewarding.

Then I built Enchapter.

I&apos;m a parent and an engineer who spends my days optimizing systems. I wanted to apply that same thinking to reading motivation: what if we tracked progress the way fitness apps track steps? What if finishing a chapter felt like crossing a finish line?

Enchapter is a reading milestone tracker for kids ages 4-12. Kids read illustrated stories in the app, earn badges as they complete chapters and books, and build a visual journey that shows them how far they&apos;ve come. It&apos;s free to download, and it&apos;s designed for parents who read alongside their kids.

## Why Milestones Work

Kids love visible progress. Sticker charts work for chores. Gold stars work for homework. Enchapter brings that same psychology to reading.

Every chapter earns a badge. Every book completed adds to their collection. The app doesn&apos;t gamify reading into something it&apos;s not — there are no points, no timers, no artificial competition. It just makes progress visible.

The difference is immediate. When my kid knows there&apos;s a badge waiting at the end of the chapter, they push through the tough pages. When they can see the full shelf of books they&apos;ve read, they ask for more.

## What It Actually Does

Enchapter is simple:

1. **Illustrated stories** optimized for kids to read on an iPhone or iPad
2. **Milestone badges** that unlock as they complete chapters and books
3. **A reading journey** that shows their full library of completed books

That&apos;s it. No branching narratives. No choose-your-own-adventure mechanics. No RPG systems. Just reading and progress tracking.

The stories are written to match reading levels from early readers to middle-grade chapter books. The illustrations are colorful without being distracting. The interface is clean enough that kids can navigate it themselves but designed for parents to read alongside.

## Why I Built This Instead of Using Existing Apps

There are reading apps. There are reading trackers. But most fall into one of two traps:

1. **Over-gamification** — turning reading into a point-chasing game where the book becomes secondary to the rewards
2. **Under-motivation** — plain text trackers that feel like homework

Enchapter sits in the middle. The milestone system is motivating without being distracting. The stories are engaging without requiring interactivity. The progress is visible without being noisy.

I wanted something I&apos;d actually use with my own kid at bedtime, not something that felt like shoving them toward a screen.

## The Parent Angle

This isn&apos;t a &quot;hand them the iPad and walk away&quot; app. Enchapter works best when you&apos;re reading together. The milestone system gives you natural stopping points, and the badge collection becomes a conversation starter.

&quot;Remember when you finished that pirate story? Want to try the next one?&quot;

It turns reading from a task into a shared project.

## What&apos;s Next

Enchapter is live on the App Store, free to download. I&apos;m adding more stories, expanding the milestone system, and refining the interface based on feedback from parents who are actually using it.

If you&apos;ve got a kid who needs a little extra motivation to push through bedtime reading, or if you just want a cleaner way to track what they&apos;ve read, give it a shot. It&apos;s built by a parent who got tired of the bedtime negotiation.

**Download free on the App Store:** https://apple.co/3Q5HI2U

More at https://enchapter.kids.</content:encoded><category>technology</category><category>personal</category><category>ai</category></item><item><title>Stop Guessing If You&apos;ll Pass</title><link>https://charlieseay.com/blog/cert-prep-readiness/</link><guid isPermaLink="true">https://charlieseay.com/blog/cert-prep-readiness/</guid><description>Cert prep is just BBQ with a textbook. Low and slow beats hot and fast — and I built a thermometer.</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded># Stop Guessing If You&apos;ll Pass

You know that moment in competition BBQ where you&apos;ve had the brisket on for nine hours and someone walks up and asks, &quot;Is it done?&quot;

And you want to say, &quot;I don&apos;t know, Dave. Is 203 internal with a clean probe feel done to you? Because it sure feels done to me, but the bark says otherwise and the stall lasted two hours longer than expected.&quot;

That&apos;s cert prep. You&apos;ve been studying for weeks. You&apos;ve watched the videos. You&apos;ve taken some practice quizzes. And now you&apos;re staring at the Pearson VUE scheduling page asking yourself the same question Dave asked about the brisket: _is it done?_

Most people answer with a vibe. They scored 78% on a practice test last Tuesday and figure that&apos;s close enough. They&apos;ve been at it for three months and feel like they _should_ be ready by now.

Vibes are not a thermometer.

## The brisket problem

Here&apos;s what actually determines whether you&apos;re ready, and it&apos;s the same thing that determines whether the brisket is ready — multiple variables that interact with each other, not a single number.

**Coverage.** Did you actually study all the domains, or did you spend 80% of your time on networking because it&apos;s interesting and 20% on governance because it puts you to sleep? That&apos;s like smoking one side of the brisket and hoping the other side figures it out. The exam doesn&apos;t care which topics you enjoyed. It&apos;s testing all of them.

**Weak spots.** A 75% average that&apos;s evenly spread across five domains is a completely different animal than a 75% that comes from crushing four domains and bombing one. One of those people is ready. The other one is about to get wrecked by whichever 15 questions land on their blind spot. Know your cold spots before the exam finds them for you.

**Time vs. complexity.** A CompTIA A+ and an AWS Solutions Architect are not the same cook. The A+ is a pork butt — forgiving, broad, hard to completely screw up if you put in the hours. The SAA is a beef rib — less margin for error, demands precision, punishes you for skipping steps. Your experience level is the quality of the smoker. A good one cuts hours off the cook. Same with certs.

**Consistency.** Seven hours across five days beats seven hours crammed into a Saturday. This is the low-and-slow principle applied to your brain. Weekend cram sessions feel productive the way cranking the heat to 350 feels fast. You&apos;ll get something out of it, but it won&apos;t be what you wanted.

## I got tired of doing napkin math

Every time I picked up a new cert track, I&apos;d do the same thing: look up the recommended study hours, guess at my experience level, divide by how many hours I could realistically study per week, and scribble a timeline on whatever was nearby.

Then I&apos;d break that timeline into phases mapped to the exam domains, weighted by their percentage of the actual test. Then I&apos;d do it again two weeks later because I lost the napkin.

So I built a thing. The [Cert Prep Calculator](https://hone.academy/tools/cert-calculator) asks four questions — which cert, your experience, your weekly hours, and whether you&apos;ve passed certs before — and hands you a phased study plan with a timeline.

Four inputs. One output: how many weeks until you&apos;re ready, and exactly where to spend that time. No account required. No napkins harmed.

## What to do when the thermometer reads back

The calculator gives you a range, not a date. What you do with that range is the difference between a medal and a &quot;thanks for participating.&quot;

**Early in the range?** You&apos;re building the foundation. This is the part where the brisket is just sitting there looking the same as it did two hours ago and you&apos;re tempted to crank the heat. Don&apos;t. The stall is where the magic happens. Same with studying — this is where real understanding forms, not pattern recognition that falls apart under exam pressure.

**Past the midpoint?** Stop learning new material. Start identifying and closing gaps. Take timed practice tests. Note which domains consistently score lower. Spend your remaining time there. Reviewing material you already know is the study equivalent of opening the smoker to check on it every 15 minutes — it feels like you&apos;re doing something, but you&apos;re actually losing heat.

**At or past the end of the range?** Pull it. Schedule the exam this week, not next month. The most common failure mode for well-prepared people is marinating in anxiety instead of taking the test. If you&apos;ve put in the hours and you&apos;re consistently scoring above the pass line, you&apos;re done. The extra week of review won&apos;t add a single point. The stress of delaying might cost you five.

## The receipt

Here&apos;s the thing about certifications that the cert industry doesn&apos;t love hearing: the paper is a receipt. The studying is the meal.

A cert proves you crossed a bar on a specific day. The preparation — the weeks of structured learning, the gap identification, the forced breadth across topics you&apos;d otherwise ignore — that&apos;s what actually made you better. The cert is just proof you showed up and the brisket was good.

This doesn&apos;t make certs pointless. They&apos;re useful signal, they force structure, and they give you a framework when self-study starts to feel like wandering. But if you&apos;re chasing the paper without engaging with the material, you&apos;re buying the trophy without entering the competition.

Study like you&apos;re trying to learn. The pass is a side effect.</content:encoded><category>certifications</category><category>career</category><category>tools</category></item><item><title>From Paperweight to Miner with Bench</title><link>https://charlieseay.com/blog/from-paperweight-to-miner/</link><guid isPermaLink="true">https://charlieseay.com/blog/from-paperweight-to-miner/</guid><description>A oneShot miner sat dead on my desk for months. Building an MCP server that could see USB hardware turned it into the investigation that brought it back to life — and uncovered some things the manufacturer probably hoped I wouldn&apos;t notice.</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>I bought a oneShot miner — a tiny ESP32-based Bitcoin mining device with a 2.8-inch display. It was supposed to plug in via USB, connect to WiFi, and start hashing. Instead, the display hung at 70% during boot, the web interface was nowhere to be found, and the documentation was thin enough to see through. It became a paperweight.

It sat on my desk for months. Occasionally I&apos;d plug it in, stare at the frozen boot screen, unplug it, and go back to whatever I was actually doing.

What changed wasn&apos;t patience or a troubleshooting breakthrough. It was building a tool for a completely different reason that happened to solve this problem too.

## The tool that changed the equation

[Bench](https://github.com/seayniclabs/bench) is an MCP server I built to give AI coding assistants direct visibility into USB hardware. MCP — Model Context Protocol — is the standard that lets tools like Claude Code call external capabilities. Bench exposes what&apos;s physically plugged into your machine: device names, vendor IDs, serial ports, storage volumes. The kind of information you&apos;d normally get by running `system_profiler` or `lsusb` and squinting at the output.

I didn&apos;t build Bench for the miner. I built it because every time I plugged in an Arduino or an ESP32, my AI assistant had no idea it existed. I&apos;d have to manually find the serial port, figure out the device type, and paste that information into the conversation. Bench eliminates that — the AI can query the hardware directly.

Once the tool existed, I had a thought: what about that dead miner sitting three inches from my keyboard?

## Seeing the device for the first time

I asked Claude Code to list USB devices using Bench. For the first time, the miner had an identity:

| Property | Value |
|----------|-------|
| Chip | CH340 USB-Serial Adapter |
| Vendor | QinHeng Electronics (0x1A86) |
| Serial Port | `/dev/cu.usbserial-2120` |

A CH340 — a common USB-to-serial bridge chip, the kind you find on cheap ESP32 development boards. Now I had a serial port. If there&apos;s a serial connection, there&apos;s likely more to this device than a frozen LCD.

## Finding the web server

The miner had connected to my WiFi before hanging. A network scan turned up a device at `192.168.0.132` serving HTTP on port 80. Hitting it in a browser revealed a full mining monitor dashboard — hashrate, temperature, pool configuration, wallet addresses.

The device wasn&apos;t dead. It was mining. The display was broken, but the mining software was running fine underneath. The firmware — NMMiner Monitor by NMTech — exposed a web interface with API endpoints:

| Endpoint | What it does |
|----------|-------------|
| `/swarm` | Live device status — hashrate, temperature, memory, uptime |
| `/config` | Full device configuration — pools, wallets, WiFi, display settings |
| `/broadcast-config` | Push new configuration to the device |

## What the manufacturer doesn&apos;t tell you

I pulled the config. Here&apos;s what came back (sanitized):

```json
{
  &quot;ssid&quot;: &quot;&lt;your-wifi-network&gt;&quot;,
  &quot;wifiPass&quot;: &quot;&lt;your-wifi-password-in-plaintext&gt;&quot;,
  &quot;PrimaryAddress&quot;: &quot;18dK8EfyepKuS74fs27iuDJWoGUT4rPto1&quot;,
  &quot;SecondaryAddress&quot;: &quot;18dK8EfyepKuS74fs27iuDJWoGUT4rPto1&quot;
}
```

**Your WiFi password is returned in plaintext** from an unauthenticated HTTP endpoint. Anyone on your local network can read it by visiting a URL. No login. No token. No authentication of any kind.

That wallet address — `18dK8EfyepKuS74fs27iuDJWoGUT4rPto1` — isn&apos;t mine. It&apos;s the manufacturer&apos;s. **Out of the box, this device mines Bitcoin for NMTech, not for you.** Both the primary and secondary wallet addresses ship configured to the same manufacturer wallet. Unless you find the web interface (which requires knowing the device&apos;s IP, undocumented) and manually change the configuration, every hash your device computes enriches someone else&apos;s wallet.

The device works. It connects to a mining pool. It hashes. It just doesn&apos;t hash for *you*.

### The full security picture

Since I was already in the weeds, I documented everything:

| Finding | Severity |
|---------|----------|
| WiFi credentials exposed in plaintext via `/config` | Critical |
| Manufacturer&apos;s wallet hardcoded as default on both pools | Critical |
| No authentication on any endpoint — dashboard, config, or broadcast | High |
| No HTTPS — all data transmitted in cleartext | High |
| Pool passwords visible in configuration | Medium |
| System metrics (temperature, memory, RSSI, uptime) exposed to network | Info |

None of this is encrypted. None of it is authenticated. Anyone on your LAN can read your WiFi password, change the mining wallet, push new configuration to the device, or monitor what it&apos;s doing. The `/broadcast-config` endpoint accepts arbitrary configuration changes from any device on the network.

## Fixing the firmware

The display issue turned out to be a known bug. The miner shipped with firmware v1.8.10, and the NMMiner GitHub issues were full of reports: boot hangs at 70-80%, display freezes, pool connectivity failures. Fixes landed across v1.8.24 through v1.8.27.

NMMiner provides a browser-based flash tool at `flash.nmminer.com`, but it requires Chrome&apos;s Web Serial API. I used `esptool` from the command line instead:

```bash
# Install esptool
brew install esptool

# Download the v1.8.27 firmware binaries
curl -sO &quot;https://flash.nmminer.com/firmware/v1.8.27/esp32-2432s028r-ili9341/bootloader.bin&quot;
curl -sO &quot;https://flash.nmminer.com/firmware/v1.8.27/esp32-2432s028r-ili9341/partitions.bin&quot;
curl -sO &quot;https://flash.nmminer.com/firmware/v1.8.27/esp32-2432s028r-ili9341/boot_app0.bin&quot;
curl -sO &quot;https://flash.nmminer.com/firmware/v1.8.27/esp32-2432s028r-ili9341/firmware.bin&quot;

# Flash the device
esptool --chip esp32 --port /dev/cu.usbserial-2120 --baud 460800 \
  write_flash \
  0x1000 bootloader.bin \
  0x8000 partitions.bin \
  0xe000 boot_app0.bin \
  0x10000 firmware.bin
```

Notice the firmware path includes `ili9341`. That&apos;s the LCD driver — and I didn&apos;t get it right the first time.

NMMiner&apos;s documentation says their branded boards use the ST7789 display driver. I flashed the ST7789 variant first. Mining worked — 1.03 MH/s, shares accepted, pool connected — but the display was solid white. A glowing white rectangle.

The underlying board is a CYD — &quot;Cheap Yellow Display&quot; — an ESP32-2432S028R. These boards ship with either ST7789 or ILI9341 LCD controllers, and there&apos;s no reliable way to tell from the outside. NMMiner&apos;s docs say one thing; the hardware says another. When the documentation and the hardware disagree, the hardware wins. Cheap ESP32 boards are not known for their documentation accuracy.

I reflashed with the ILI9341 variant. Display came right up. WiFi credentials survived the flash — the device reconnected automatically, no AP setup required.

## Configuring the device

With the firmware updated and display working, the last step was pushing correct configuration through the API:

- **Wallet:** Changed both primary and secondary to my own address
- **Timezone:** Changed from 8 (China Standard Time) to -5 (Central Daylight Time) — the device doesn&apos;t handle DST automatically, so you set the raw UTC offset
- **Display:** Brightness to 100, auto-brightness enabled (the original display &quot;failure&quot; turned out to be brightness set to 0 in the config)
- **Pool:** `stratum+tcp://solobtc.nmminer.com:3333`

All through `curl` to the `/broadcast-config` endpoint. No app. No special tooling. Just HTTP and JSON.

## The result

The miner runs at 1.03 MH/s with 100% share acceptance. The display shows hashrate, pool status, and the correct time. Most importantly, it mines for the right wallet.

Will it ever mine a Bitcoin block solo? The odds are astronomical — an ESP32 doing SHA-256 at one megahash per second, competing against ASICs doing hundreds of terahashes. It&apos;s a lottery ticket that costs a few cents of electricity per month. But it&apos;s *my* lottery ticket now.

## What Bench made possible

I didn&apos;t sit down to fix a miner. I sat down to build an MCP server that gives AI tools hardware awareness. The miner investigation was a side effect — a &quot;let&apos;s see what happens if I point this at that dead device&quot; moment that turned into a full security audit.

That&apos;s the value of giving your AI assistant access to the physical world. Not just identifying an Arduino&apos;s serial port (though that&apos;s useful) — lowering the friction between &quot;I wonder what this thing is&quot; and actually finding out. Bench gave Claude Code the ability to see the CH340 chip, find the serial port, and from there, the investigation unfolded naturally.

The miner went from paperweight to functioning device in one session. The security findings are a bonus — or a warning, depending on how you look at it. If you own one of these devices and haven&apos;t changed the wallet address, you&apos;re mining for the manufacturer. Check your config.

*Since this post was written, Bench has grown from 5 tools to 16 — including real-time device monitoring, hub topology mapping, firmware flashing, HID control, power diagnostics, and identification of 83+ maker boards. It&apos;s free and open source.*

[Bench on GitHub](https://github.com/seayniclabs/bench)</content:encoded><category>hardware</category><category>mcp</category><category>security</category><category>bitcoin</category><category>homelab</category></item><item><title>One Claude, Two Lives</title><link>https://charlieseay.com/blog/one-claude-two-lives/</link><guid isPermaLink="true">https://charlieseay.com/blog/one-claude-two-lives/</guid><description>How a symlink and a git repo evolved into a full portable toolkit — agents, templates, skills, and a drift check that keeps two separate environments in sync without mixing data.</description><pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate><content:encoded>A few days ago I wrote about [solving portable Claude context with a symlink](/blog/portable-claude-context) instead of an MCP server. The setup was simple: a private GitHub repo with my `CLAUDE.md`, cloned on two machines, symlinked into place. Two commands. Done.

That was the right solution for the original problem. But the original problem was small: make Claude remember who I am on both machines.

The problem grew.

## The toolkit outgrew the file

The first version of `claude-context` had three things: a `CLAUDE.md` file, a persona definition, and six agent markdown files (code review, technical writing, editing, specs, research, test analysis). Everything Claude needed to know about how I work, loaded at session start.

Then I started building skills — custom slash commands that Claude Code executes like macros. `/checkpoint` stages and pushes all my repos. `/pdf` converts markdown to styled PDFs. `/runbook` generates operational documentation with rollback steps and escalation contacts.

Then came templates. Meeting notes with decisions and action item tables. Architecture decision records with auto-numbering. 1:1 coaching notes with growth goal tracking. Process documentation with Mermaid flowcharts.

Then more agents. A change advisor that evaluates blast radius before I approve anything. A mentor coach that helps me prepare for 1:1s. A process mapper for documenting the gap between &quot;how we do it&quot; and &quot;how we should do it.&quot; An incident reporter that structures blameless post-mortems.

The repo went from 8 files to 30. And the problem shifted from &quot;Claude doesn&apos;t know who I am&quot; to &quot;Claude doesn&apos;t have access to how I work.&quot;

![Architecture diagram showing Claude Code and Gemini CLI sharing a toolkit from GitHub, both reading and writing to an Obsidian vault, with git checkpoint syncing and drift detection](/images/blog/one-claude-two-lives-diagram.svg)

## The constraint that shaped everything

I use Claude Code in two environments. One is personal — side projects, home lab infrastructure, this blog. The other is work — enterprise engineering at a healthcare company. Different machine, different vault, different data.

The rule is absolute: **work data and personal data do not mix.** Not in the same vault, not in the same repo, not in the same conversation. There&apos;s no gray area here. Patient-adjacent systems, compliance requirements, corporate policy — the wall exists for good reasons.

But the way I work doesn&apos;t change between 9 AM and 9 PM. I still want structured meeting notes. I still want architecture decision records. I still want an agent that asks &quot;what breaks if this goes wrong?&quot; before I approve a change. The *tools* are the same. The *data* they touch is completely separate.

That&apos;s the design constraint: **share the toolkit, never the content.**

## What&apos;s portable and what isn&apos;t

The dividing line from the first post still holds — if it describes *how I work*, it&apos;s portable. If it describes *what I&apos;m working on*, it stays local. But with 30 files, the line needed to be explicit.

**Portable (lives in `claude-context`, syncs to both environments):**

| Type | Examples |
|------|----------|
| Skills | `/meeting-notes`, `/decision-record`, `/runbook`, `/pdf` |
| Agents | CodeReview, ChangeAdvisor, MentorCoach, ProcessMapper, IncidentReporter, Scribe, Editor |
| Templates | Meeting Notes, Decision Record, Runbook, 1:1 Notes, Process Doc |

**Stays local (environment-specific, not portable):**

| Type | Examples |
|------|----------|
| Skills | `/tailor` (tied to personal resume) |
| Agents | LabOps (home lab infrastructure), BlenderArtist (3D modeling), AppStoreOptimizer (iOS app), InfrastructureMaintainer |
| Templates | Lab Note, Opportunity (market evaluation), Marketing Plan |
| Data | Everything in each Obsidian vault, project notes, accumulated memory |

Some skills straddle the line. `/checkpoint` lives in the repo and syncs everywhere, but it detects which machine it&apos;s on — full behavior on the personal Mac (15 repos, build verification, Homepage sync), stripped-down fallback on any other machine (just commit and push whatever directory you&apos;re in). Same file, environment-aware behavior.

The general test: could someone on a different team, with different projects, use this tool and get value from it? If yes, it&apos;s portable. If it assumes knowledge of my specific infrastructure, repos, or projects, it stays local.

## The repo structure now

```
claude-context/
├── CLAUDE.md                     ← global context (symlinked to ~/.claude/CLAUDE.md)
├── personas/
│   └── charlie-seay.md
├── agents/
│   ├── change-advisor.md         ← new
│   ├── code-review.md
│   ├── editor.md
│   ├── incident-reporter.md      ← new
│   ├── mentor-coach.md           ← new
│   ├── process-mapper.md         ← new
│   ├── research.md
│   ├── scribe.md
│   ├── tech-spec.md
│   └── test-results-analyzer.md
├── commands/
│   ├── meeting-notes.md          ← new
│   ├── decision-record.md        ← new
│   ├── runbook.md                ← new
│   ├── pdf.md
│   ├── clone.md
│   ├── brand-name.md
│   ├── checkpoint.md
│   └── ...
└── templates/
    ├── 1-1-notes.md              ← new
    ├── decision-record.md        ← new
    ├── meeting-notes.md          ← new
    ├── process-doc.md            ← new
    └── runbook.md                ← new
```

Setup on a new machine is still two commands:

```bash
git clone https://github.com/youruser/claude-context.git ~/Projects/claude-context
ln -sf ~/Projects/claude-context/CLAUDE.md ~/.claude/CLAUDE.md
```

The symlink gives Claude Code your global context at session start. Skills in `commands/` get symlinked to `~/.claude/commands/` so they&apos;re available as slash commands everywhere. Agents and templates are referenced by the global `CLAUDE.md`, which points to them relative to the repo root.

## The drift problem

Here&apos;s where it gets interesting. I also have an Obsidian vault for personal projects. Obsidian has a Templates plugin that inserts templates from a `Templates/` folder. Agents live in an `Agents/` folder where Claude reads them for task-specific behavior.

So the same files exist in two places: the `claude-context` repo (canonical, git-synced) and the Obsidian vault (where I actually use them day-to-day). Edit a template in Obsidian, forget to update the repo — drift. Push a new agent definition through the repo, forget to copy it to the vault — drift.

Drift is the thing that kills two-copy systems. Not immediately. Slowly. You don&apos;t notice until you&apos;re on the work machine and the meeting notes template is missing the &quot;Follow-up&quot; section you added two weeks ago.

## The fix: automated drift detection

My `/checkpoint` skill already runs after every work session — it stages, commits, and pushes all tracked repos. I added a step: after committing everything, diff every portable file between the repo and the vault.

The mapping table handles the naming convention difference (the repo uses `kebab-case`, the vault uses `Title Case`):

```markdown
| claude-context              | Vault                          |
|-----------------------------|--------------------------------|
| agents/change-advisor.md    | Agents/ChangeAdvisor.md        |
| agents/code-review.md       | Agents/CodeReview.md           |
| templates/meeting-notes.md  | Templates/Meeting Notes.md     |
| templates/1-1-notes.md      | Templates/1-1 Notes.md         |
| ...                         | ...                            |
```

If any pair differs, checkpoint reports which files drifted, shows a summary of the differences, and asks which version to keep. Then it copies the winner to the other location and commits.

On the work machine, where vault paths don&apos;t exist, the check skips entirely. It only runs where both locations are present.

This isn&apos;t clever. It&apos;s a `diff` in a loop. But it catches the problem that actually kills multi-copy systems: the quiet divergence you don&apos;t notice until it matters.

## What I&apos;d do differently for a team

This setup is built for one person across two environments. If I were scaling it for a team, a few things would change:

**What stays the same:** The repo-as-source-of-truth pattern. Git is the right sync layer for structured text files. Everyone clones, everyone pulls. It&apos;s solved infrastructure.

**What changes:**

- **Templates become a shared library, not a personal toolkit.** A team needs consensus on what a decision record looks like. That&apos;s a conversation, not a solo design decision.
- **Agents get scoped.** My ChangeAdvisor asks questions that matter to me specifically — blast radius, rollback time, stakeholder notification. A team&apos;s version might weight differently based on their deployment model.
- **The drift check becomes CI.** Instead of running at checkpoint, a GitHub Action diffs against a known-good state and flags PRs that modify shared templates without updating the version.
- **Skills need documentation, not just implementation.** `/runbook` works because I wrote it and know what it expects. A team member picking it up needs a README, examples, and probably a `--help` flag.

But honestly? Start with one person&apos;s portable repo. If it sticks, the team adoption path is just &quot;clone this and tell me what&apos;s missing.&quot;

## The pattern

If you&apos;re using Claude Code across environments — work and personal, desktop and laptop, or even just &quot;my main machine&quot; and &quot;the one I use on the couch&quot; — here&apos;s the pattern:

1. **Create a private repo** for your portable AI toolkit
2. **Put your global CLAUDE.md, agents, templates, and skills in it**
3. **Symlink the CLAUDE.md** to `~/.claude/CLAUDE.md` on each machine
4. **Draw the line** between what&apos;s portable (how you work) and what&apos;s local (what you&apos;re working on)
5. **Detect drift** if the same files exist in multiple locations — automate the check so you don&apos;t have to remember

The separation isn&apos;t just organizational hygiene. It&apos;s what makes the toolkit trustworthy. When I type `/meeting-notes` at work, I know it&apos;s pulling from the same template I refined on a personal project last weekend — and I know it&apos;s not pulling anything else.

One Claude. Two lives. Same toolkit. Separate data. That&apos;s the whole thing.</content:encoded><category>ai</category><category>claude-code</category><category>workflow</category><category>devops</category><category>productivity</category></item><item><title>Why Certification Prep Matters for DevOps Engineers</title><link>https://charlieseay.com/blog/why-cert-prep-matters/</link><guid isPermaLink="true">https://charlieseay.com/blog/why-cert-prep-matters/</guid><description>IT certifications aren&apos;t just resume padding. Here&apos;s how structured cert prep builds real skills and accelerates your infrastructure career.</description><pubDate>Wed, 11 Mar 2026 00:00:00 GMT</pubDate><content:encoded># Why Certification Prep Matters for DevOps Engineers

There&apos;s a running debate in the infrastructure world: do certifications actually matter? Some engineers dismiss them as checkbox exercises. Others swear by them as career accelerators. The truth sits somewhere in between — and it depends entirely on _how_ you prepare.

## The Problem With &quot;Just Studying&quot;

Most certification prep looks like this: buy a massive course, watch 40 hours of video, cram practice dumps the week before the exam, and hope for the best. This approach checks the cert box but leaves gaps in actual understanding.

The engineers who get the most value from certifications treat them differently. They use the exam objectives as a structured map of what they need to know — then they go _deeper_ than the exam requires.

## What Good Cert Prep Actually Builds

### Mental Models, Not Memorization

A well-designed quiz doesn&apos;t just ask &quot;what&apos;s the answer?&quot; — it forces you to reason through _why_ the answer is correct and _why_ the alternatives are wrong. That reasoning builds mental models you&apos;ll use daily.

When you understand _why_ Terraform state locking prevents concurrent modifications (not just _that_ it does), you make better architectural decisions.

### Gap Identification

The biggest value of structured assessment isn&apos;t the score — it&apos;s finding what you don&apos;t know. Most engineers have blind spots. Maybe you&apos;re strong on Kubernetes networking but fuzzy on RBAC. A good quiz surfaces those gaps before they surface in production.

### Breadth Across the Stack

DevOps roles demand breadth. You might be deep in AWS but have never touched Azure&apos;s resource model. Certification tracks force you to at least survey the full landscape of a platform, which makes you more versatile and more dangerous in a good way.

## Certifications Worth Pursuing in 2026

If you&apos;re starting fresh or adding to your collection, these certifications offer strong signal to employers and genuine skill-building value:

- **AWS Cloud Practitioner (CLF-C02)** — Best starting point for cloud fundamentals
- **AWS Solutions Architect Associate (SAA-C03)** — The gold standard for cloud architecture roles
- **Kubernetes (CKA/CKAD)** — Hands-on, performance-based exams that test real skills
- **Terraform Associate (003/004)** — Infrastructure as code is non-negotiable in modern ops
- **CompTIA Security+** — Baseline security knowledge every engineer needs

## How I Approach Cert Prep

I&apos;ve been building a tool called [Hone](https://hone.academy) that takes a different approach. Instead of video courses and brain dumps, it gives you structured quizzes aligned to real exam objectives — with detailed explanations that teach the _why_ behind every answer.

The goal isn&apos;t to replace hands-on practice. It&apos;s to identify your gaps, strengthen your weak areas, and walk into the exam knowing exactly where you stand. You can [try free practice questions](https://hone.academy/cert) for any of the supported certifications without creating an account.

Whether you use Hone or something else, the principle is the same: treat the exam objectives as a learning framework, not a checklist. The cert is the byproduct. The skill is the point.</content:encoded><category>certifications</category><category>devops</category><category>career</category></item><item><title>Trust Forwarded Proto: The One NPM Setting That Breaks Cloudflare Tunnel</title><link>https://charlieseay.com/blog/trust-forwarded-proto/</link><guid isPermaLink="true">https://charlieseay.com/blog/trust-forwarded-proto/</guid><description>If you&apos;re getting redirect loops through a Cloudflare Tunnel and Nginx Proxy Manager setup, it&apos;s probably one checkbox you didn&apos;t know existed.</description><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><content:encoded>You set up Cloudflare Tunnel. You pointed it at Nginx Proxy Manager. You enabled Force SSL on your proxy hosts. You open the subdomain and get:

&gt; **Safari can&apos;t open the page — too many redirections.**

No misconfigured DNS. No typo in the tunnel config. The issue is one checkbox you didn&apos;t know existed.

## How the redirect loop forms

![Diagram showing the redirect loop caused by SSL termination at Cloudflare and Force SSL in NPM, and the fix using X-Forwarded-Proto](/images/blog/trust-forwarded-proto-diagram.svg)

The traffic path looks like this:

```
Browser → Cloudflare Edge (HTTPS) → cloudflared tunnel → NPM (HTTP on port 80) → your service
```

Cloudflare handles SSL termination at the edge. It then forwards the request to your tunnel as plain HTTP — because that&apos;s the internal transport. NPM receives an HTTP request, sees that Force SSL is enabled, and redirects to HTTPS. That HTTPS request arrives at Cloudflare, gets forwarded to the tunnel as HTTP again. Repeat forever.

The fix is telling NPM to trust the `X-Forwarded-Proto` header that Cloudflare sends. When NPM sees `X-Forwarded-Proto: https`, it knows the original request was already secure and skips the redirect.

## The fix

In NPM, open each proxy host → **Advanced** tab → enable **Trust Forwarded Proto**.

That&apos;s it. The redirect loop stops immediately.

## The catch: the NPM API doesn&apos;t set this by default

If you created your proxy hosts through the NPM UI, you probably already have this set — the UI enables it automatically for Cloudflare-proxied domains.

If you created hosts via the NPM REST API (useful for scripted homelab setups), `trust_forwarded_proto` defaults to `false`. You have to pass it explicitly:

```json
{
  &quot;domain_names&quot;: [&quot;wallos.yourdomain.com&quot;],
  &quot;forward_host&quot;: &quot;host.docker.internal&quot;,
  &quot;forward_port&quot;: 8282,
  &quot;forward_scheme&quot;: &quot;http&quot;,
  &quot;ssl_forced&quot;: true,
  &quot;trust_forwarded_proto&quot;: true
}
```

If you&apos;ve scripted host creation and skipped this field, every new service will hit the same redirect loop until you track it down again.

## Debugging checklist

If you&apos;re getting redirect loops through a Cloudflare Tunnel → NPM setup:

1. **Check Trust Forwarded Proto** — NPM proxy host → Advanced → enabled?
2. **Check Cloudflare SSL mode** — should be **Full (strict)**, not Flexible. Flexible creates its own redirect loops independently of this issue.
3. **Check the forward scheme** — if NPM is forwarding to your service over HTTPS with a self-signed cert, enable **Disable Certificate Verification** on that host.
4. **Check for loops at the service level** — some apps do their own HTTP → HTTPS redirect internally. Same root cause, same fix: the app needs to trust `X-Forwarded-Proto` too.

## Two services, same root cause

This issue doesn&apos;t always look like a redirect loop. Authentik, for example, produces a different symptom from the same underlying problem.

When Authentik sits behind NPM and doesn&apos;t trust the forwarded proto, it generates OAuth redirect URIs with `http://` instead of `https://`. Google OAuth rejects `http` redirect URIs outright — so instead of a redirect loop, you get a `redirect_uri_mismatch` error. Different error message, identical cause.

The fix in Authentik&apos;s compose:

```yaml
environment:
  AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: &quot;0.0.0.0/0&quot;
```

And in NPM&apos;s advanced config for the Authentik proxy host:

```nginx
proxy_set_header X-Forwarded-Proto &quot;https&quot;;
```

The pattern generalizes: any service behind a Cloudflare Tunnel that makes decisions based on the request protocol — redirects, OAuth URIs, cookie security flags, HSTS enforcement — needs to know the original request was HTTPS. The tunnel strips that information. `X-Forwarded-Proto` puts it back.

---

When you&apos;re behind a Cloudflare Tunnel, your services never see a raw HTTPS request — only the forwarded header tells them what the client actually used. Everything downstream needs to trust that header, or it&apos;ll second-guess the protocol and break in ways that look nothing like the root cause.</content:encoded><category>homelab</category><category>cloudflare</category><category>docker</category><category>devops</category><category>nginx</category></item><item><title>I Almost Built an MCP Server to Solve a Problem That Needed a Symlink</title><link>https://charlieseay.com/blog/portable-claude-context/</link><guid isPermaLink="true">https://charlieseay.com/blog/portable-claude-context/</guid><description>How I set up portable Claude Code context across two machines — and why the first plan was four layers of infrastructure too many.</description><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><content:encoded>I use Claude Code on two machines. One is personal — a home lab Mac Mini where I build projects, manage infrastructure, and write. The other is a work machine with a completely different auth setup. Different device, different profile, no shared filesystem.

The problem: Claude Code doesn&apos;t know who I am when I switch machines. I&apos;ve spent months building up context — a persona file that describes how I think and communicate, agent definitions that encode how I want code reviewed or documentation written, preferences for tone and structure that make Claude actually useful instead of generically helpful. On the personal machine, it works beautifully. On the work machine, I start from zero every time.

I wanted my context to follow me.

## The plan I almost built

Late one night, I drafted a four-part system:

1. **Obsidian Git Plugin** syncing a `context-library/` folder from my Obsidian vault to a private GitHub repo on every save
2. **A custom MCP server** running on my Mac Mini, using the GitHub API to fetch context files and serve them to Claude Code as tool responses
3. **An HTTP bridge** wrapping the MCP server in an Express app so it could be accessed remotely
4. **Cloudflare Tunnel + SSO** exposing the HTTP bridge at `context.mydomain.com`, gated behind my existing authentication stack

It was well-designed. The MCP server had three tools — `get_context` for fetching specific files, `list_context` for browsing the library, and `search_context` for keyword lookup. The HTTP bridge had bearer token auth. I even had a `launchd` plist drafted for running it as a background service on the Mac Mini.

It looked like this:

```
Obsidian → GitHub (private repo)
                  ↓
           Mac Mini MCP server  ←→ Claude Code (local)
                  ↓
         Cloudflare Tunnel + SSO
                  ↓
           Work machine  ←→ Claude Code (remote)
```

I was one `npm init` away from building it.

## The question that killed it

Before starting, I asked myself the question I tell Claude to ask me: *what are you actually trying to solve?*

The answer was simple: I want my persona, preferences, and agent definitions available when Claude Code starts a session — on any machine.

Not dynamically. Not searchable. Not over HTTP. I don&apos;t need to pull different personas mid-conversation or query my context library from my phone. I just need Claude to know who I am when the session begins.

Claude Code already has a mechanism for this: a file called `CLAUDE.md` in your home directory at `~/.claude/CLAUDE.md`. It loads automatically at the start of every session, on every project. It&apos;s the global system prompt.

The entire problem is: that file exists on one machine and not the other.

## The solution that actually shipped

![Diagram showing the portable Claude context setup — a GitHub repo symlinked into ~/.claude/ on two machines, each running independent Claude Code sessions with the same context](/images/blog/portable-claude-context-diagram.svg)

A private GitHub repo with my context files, cloned on both machines, symlinked to `~/.claude/CLAUDE.md`.

```bash
# One-time setup on each machine
git clone https://github.com/youruser/claude-context.git ~/Projects/claude-context
ln -sf ~/Projects/claude-context/CLAUDE.md ~/.claude/CLAUDE.md
```

That&apos;s it. Two commands.

The repo looks like this:

```
claude-context/
├── CLAUDE.md                    ← symlinked to ~/.claude/CLAUDE.md
├── personas/
│   └── charlie-seay.md          ← full persona reference
└── agents/
    ├── code-review.md
    ├── scribe.md
    ├── editor.md
    ├── tech-spec.md
    ├── research.md
    └── test-results-analyzer.md
```

The `CLAUDE.md` file contains everything Claude needs to load at session start: who I am, how I communicate, my working style and preferences, writing registers (I write differently for documentation than I do for blog posts — and I want Claude to match that), a summary of each agent&apos;s capabilities, and the rules for how we work together.

The agent files have full definitions if Claude needs deeper context for a specific task, but the summaries in `CLAUDE.md` cover 90% of sessions.

To sync: edit on either machine, `git push`, `git pull` on the other. I added it to my existing checkpoint script so it pushes alongside my other repos automatically.

## What goes in the portable file and what stays local

This was the harder design decision — not how to sync, but what to sync. I have 15 agent definitions, detailed infrastructure notes, project-specific context, and a full memory system. Most of it is useless on the work machine.

**Portable (goes in the repo):**
- My persona — professional arc, working style, communication preferences
- The &quot;how to work with me&quot; rules — push back on bad ideas, question everything, accuracy over agreeableness
- Writing registers — four distinct voices for different contexts, with defaults
- General-purpose agents — code review, technical writing, editing, specs, research, test analysis
- Multi-model routing preferences — when to use Claude vs. other tools

**Stays local (project-specific, not portable):**
- Home lab infrastructure details (ports, IPs, Docker configs)
- Personal project agents (home lab ops, content distribution, app store optimization)
- Accumulated memory about specific projects
- Vault-specific configuration

The dividing line: if it describes *how I work*, it&apos;s portable. If it describes *what I&apos;m working on*, it stays local.

## Why the MCP server was wrong

The MCP approach wasn&apos;t technically flawed — it would have worked. But it violated a principle I apply to every build: *is this the simplest solution that works?*

An MCP server makes sense when you need **dynamic, queryable context** — switching personas mid-conversation, searching across hundreds of documents, pulling different context based on the task. If I had a team of people each with their own profiles, or if my context library were large enough that loading it all at once was wasteful, MCP would be the right tool.

But my use case is one person, one file, loaded once at session start. The &quot;dynamic&quot; part is `git pull`.

Here&apos;s the infrastructure I avoided deploying:

| Planned | Purpose | Actually needed? |
|---------|---------|-----------------|
| Obsidian Git plugin | Auto-sync vault to GitHub | No — I already push manually via checkpoint |
| Node.js MCP server | Serve context files via GitHub API | No — files load from disk |
| Express HTTP bridge | Remote access to MCP tools | No — both machines have Git |
| Cloudflare Tunnel rule | Expose bridge to the internet | No — nothing to expose |
| `launchd` service | Keep MCP server running | No — nothing to keep running |
| Fine-grained PAT | Scope GitHub API access | No — standard Git auth is fine |
| Bearer token auth | Protect the HTTP bridge | No — no HTTP bridge |

Seven components, zero needed. The solution is a symlink.

## The general pattern

If you&apos;re using Claude Code on multiple machines and want consistent behavior, here&apos;s the pattern:

1. **Create a private GitHub repo** with a `CLAUDE.md` file containing your preferences, communication style, and any agent definitions you want available everywhere
2. **Clone it on each machine** and symlink `CLAUDE.md` to `~/.claude/CLAUDE.md`
3. **Keep project-specific context in project-level `CLAUDE.md` files** — these live in each project&apos;s repo and load automatically when you&apos;re working in that directory
4. **Sync with `git push` / `git pull`** — or add it to whatever deployment script you already use

The layering is clean: global context (who you are) loads from `~/.claude/CLAUDE.md`, project context (what you&apos;re building) loads from the project&apos;s `CLAUDE.md`, and Claude merges both. You don&apos;t have to choose between portable and specific — you get both.

## The lesson, again

Every infrastructure problem looks like it needs infrastructure. An MCP server is a cool tool — I&apos;ve built them for other things and they&apos;re genuinely useful. But &quot;I have a tool I like using&quot; is not the same as &quot;this problem needs this tool.&quot;

The question that saves you from overengineering is always the same: *what am I actually trying to solve?* If the answer fits in a sentence, the solution probably fits in a command.</content:encoded><category>ai</category><category>claude-code</category><category>workflow</category><category>devops</category></item><item><title>Closing the Gap: How a Mac Mini and AI Reignited 20 Years of Ideas</title><link>https://charlieseay.com/blog/closing-the-gap/</link><guid isPermaLink="true">https://charlieseay.com/blog/closing-the-gap/</guid><description>I&apos;ve had a notebook full of ideas for two decades. The gap was never imagination — it was execution. Here&apos;s what finally closed it.</description><pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate><content:encoded>I turned 40-something today. I&apos;m not going to tell you the exact number because it doesn&apos;t matter and also because I&apos;m a little bit in denial. What I will tell you is that I&apos;ve been carrying a notebook — sometimes physical, sometimes digital, always somewhere — full of ideas for the better part of 20 years.

App concepts. Business plans. Side projects. Systems I wanted to build. Problems I knew how to solve but never had the runway to sit down and solve them.

The ideas were never the problem.

## The gap

If you&apos;ve worked in tech long enough, you know the gap. It&apos;s the space between *knowing what to build* and *actually building it*. Not because you&apos;re lazy — because the activation energy is enormous.

You want to build an iOS app? Great. Learn Swift. Learn SwiftUI. Learn Xcode&apos;s opinions about how your project should be structured. Set up certificates. Figure out StoreKit. That&apos;s before you write a single line of business logic.

You want to self-host something? Cool. Spin up a container. Configure a reverse proxy. Set up DNS. Wire up authentication. Debug the one nginx directive that&apos;s silently eating your auth headers. That&apos;s before anyone can actually use the thing.

Every idea came with a tax — hours of scaffolding, configuration, and yak-shaving before you got to the part that mattered. And when you&apos;re working a full-time job, raising a family, and trying to maintain some semblance of a life outside of a terminal, that tax is a dealbreaker.

So the notebook grew. And the projects didn&apos;t.

## What changed

Three things converged in the last year, and I don&apos;t think any one of them works without the others.

### 1. The Mac Mini became a lab

I&apos;ve always had a home machine. But the M4 Pro Mac Mini hit a sweet spot I hadn&apos;t seen before: enough power to run Docker stacks, enough storage to host media, enough headroom to experiment with local AI models — and it runs 24/7 at barely a whisper on the power bill.

It&apos;s not a server rack. It&apos;s a $1,600 box on a shelf that runs my entire infrastructure. Plex, Radarr, Sonarr, a reverse proxy, SSO, a dashboard, a self-assessment platform, a portfolio site, and a kids&apos; reading app — all on one machine, all containerized, all accessible from anywhere through a Cloudflare Tunnel.

The barrier to deploying something went from &quot;figure out hosting&quot; to &quot;write a compose file and add a proxy host.&quot; That matters more than it sounds.

### 2. Obsidian became the operating system for ideas

I&apos;d tried every note-taking system. Notion. OneNote. Apple Notes. Google Docs. Plain text files in a folder called &quot;notes&quot; that I&apos;d forget about in six months.

Obsidian stuck because it works the way my brain works: everything is a file, everything links to everything else, and the structure emerges from the connections rather than being imposed upfront. My vault isn&apos;t a notebook — it&apos;s a knowledge graph. Projects link to research. Research links to ideas. Ideas link to technical specs. Specs link to build logs.

When I sit down to work on something, the context is already there. I&apos;m not starting from scratch — I&apos;m continuing a thread.

### 3. AI became a co-builder

This is the one that closed the gap.

I&apos;m not talking about asking ChatGPT to write a README. I&apos;m talking about AI as a development partner — something that can hold context across an entire project, scaffold code in languages I&apos;m still learning, debug infrastructure issues by reading logs and configs, and maintain documentation as we go.

The workflow looks like this: I describe what I want to build. We plan it together — phased, with verification steps, with a tech spec that lives in the vault. Then we build it, phase by phase. When a phase is done, we test it, document it, and checkpoint it to Git.

I built a complete iOS app — from concept to TestFlight — in a few weeks. Not because AI wrote it for me, but because AI handled the parts that used to stop me cold: the Swift syntax I didn&apos;t know yet, the StoreKit integration I&apos;d never done, the Xcode build errors that would have cost me a weekend of Stack Overflow.

The ideas in my notebook finally had a way to become real things. Not prototypes. Not half-finished repos. Deployed, running, usable things.

## What I&apos;ve shipped

In the last month alone:

- **This site** — the portfolio and blog you&apos;re reading right now. Astro, self-hosted, public.
- **A learning platform** — a self-hosted tool for reviewing and sharpening technical skills. Quiz engine, learning tracks, certification mapping.
- **An iOS app** — a native app built in SwiftUI, from first line of code to TestFlight, in a language I was still learning when I started.
- **The infrastructure itself** — the home lab that runs all of it: Docker stacks, reverse proxy, SSO, dashboard, DNS routing, IaC. All documented, all containerized.

None of these are finished. All of them are real. That&apos;s the difference.

## The execution stack

If you&apos;re curious about the actual tooling:

| Layer | What | Why |
|-------|------|-----|
| Hardware | Mac Mini M4 Pro, 24GB, 1TB NVMe | Silent, powerful, always on |
| Notes | Obsidian vault (iCloud-synced) | Knowledge graph, not a notebook |
| Containers | Docker Desktop + Portainer | Everything runs in containers |
| Routing | Cloudflare Tunnel + Nginx Proxy Manager | Zero open ports, public access |
| Auth | Authentik | Self-hosted SSO, Google OAuth |
| AI | Claude Code + Gemini | Planning, building, documenting |
| Version control | Git + `/checkpoint` | Every session ends with a commit |
| Sites | Astro (static) | Fast, content-driven, simple |

The key insight isn&apos;t any single tool. It&apos;s that the *entire pipeline* — from idea to deployed product — now fits on one machine and moves fast enough to keep up with the rate I generate ideas.

## What I actually learned

Twenty years of carrying ideas around taught me something I didn&apos;t expect: the ideas don&apos;t expire. The iOS app I&apos;m building now started as a scribble in a notebook years ago. The self-assessment platform came from a conversation about certification prep that I&apos;d been thinking about for months.

What expires is motivation. And motivation dies when the distance between &quot;I have an idea&quot; and &quot;I have a working thing&quot; is measured in months instead of days.

The gap was never talent or imagination or time management. The gap was *tooling*. The cost of turning a thought into a running application was simply too high for someone with a day job and a life.

That cost just dropped by an order of magnitude.

## What&apos;s next

The notebook isn&apos;t empty yet. There are still ideas in there — some good, some terrible, some I won&apos;t know until I build them. But the backlog is moving now, and the system that moves it is documented, repeatable, and improving.

I&apos;m not writing this to sell you on AI or home labs or Obsidian. I&apos;m writing it because if you&apos;re someone who&apos;s been carrying ideas around for years — if you&apos;ve got a graveyard of half-started repos and abandoned side projects — the execution gap is smaller than it&apos;s ever been.

The tools exist. The hardware is affordable. The AI is good enough to be a real partner, not just a fancy autocomplete.

The only thing left is to start.</content:encoded><category>personal</category><category>ai</category><category>homelab</category><category>career</category></item><item><title>How to Audit Your Stack for Offline AI Readiness</title><link>https://charlieseay.com/blog/offline-ai-readiness-audit/</link><guid isPermaLink="true">https://charlieseay.com/blog/offline-ai-readiness-audit/</guid><description>A practical framework for mapping every cloud dependency to a local alternative — with real hardware costs, model recommendations, and honest assessments of what works.</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Every API has a free tier until it doesn&apos;t. Every cloud service is reliable until it isn&apos;t. And every AI provider is affordable until the pricing page changes.

This isn&apos;t about paranoia. It&apos;s about optionality. If Anthropic raises prices, Google kills Gemini&apos;s free tier, or you just want to work from a cabin with no signal — do you have a playbook?

I built one. Here&apos;s the framework.

![Comparison diagram showing Cloud API path versus Local Inference path, with a decision matrix rating offline readiness for common development tasks](/images/blog/offline-ai-readiness-diagram.svg)

## The audit

For every cloud dependency in your stack, document four things:

1. **What it does** — the actual function, not the product name
2. **What local replacement exists** — specific tool, not &quot;something open source&quot;
3. **What hardware it needs** — RAM, VRAM, storage, with specific quantities
4. **What it costs** — real pricing, verified, not &quot;about $2K&quot;

Here&apos;s what that looks like for an AI-heavy stack running on a Mac Mini M4 Pro:

### AI services

| Function | Cloud Provider | Local Alternative | RAM Needed |
|----------|---------------|-------------------|------------|
| Coding assistant | Claude Code | Ollama + Aider + Qwen 2.5 Coder 32B | 48GB+ |
| App LLM (formatting) | Gemini 2.0 Flash | Ollama + Llama 3.3 70B Q4 | 48GB+ |
| App LLM (fallback) | Groq / Llama 3.3 70B | Same local Ollama instance | (same) |
| Image generation | Pollinations / Stable Horde | FLUX.1 or SDXL via ComfyUI | 16GB+ |
| Streaming story gen | Gemini 2.0 Flash | Ollama + Llama 3.3 70B Q4 | 48GB+ |

### Infrastructure

| Function | Cloud Provider | Local Alternative | Effort |
|----------|---------------|-------------------|--------|
| Git hosting | GitHub | Gitea or Forgejo (Docker) | Low |
| DNS + routing | Cloudflare Tunnel | dnsmasq + mDNS | Medium |
| SSL certificates | Cloudflare (auto) | mkcert (local CA) | Low |
| Auth (SSO) | Google OAuth | Authentik local passwords | Low |
| Container registry | Docker Hub | Local registry:2 + pre-pulled images | Low |
| Package manager | npm / Homebrew | Verdaccio + cached bottles | Low |

### What&apos;s already offline

This is the part most people skip. Before buying anything, check what&apos;s already local:

- **Docker, containers, reverse proxy** — already running on your machine
- **IDE** — VSCode, Xcode, everything that matters is local
- **IaC tools** — OpenTofu, Terraform, Ansible — all local binaries
- **Media server** — Plex/Jellyfin playback is local (metadata calls aside)

In my case, about 80% of the infrastructure stack is already offline-capable. The 20% that isn&apos;t is almost entirely AI and DNS.

## What fits in your RAM

This is the question. Everything else is details.

### 24GB (M4 Pro base)

You can run today — no upgrades needed:

- **Qwen 2.5 Coder 7B** (Q8) — ~5GB, good for single-file edits and autocomplete
- **Qwen 3 14B** (Q4) — ~9GB, strong reasoning with `/think` mode
- **SDXL 1.0** — ~8GB, mature ecosystem, 4-12s per image

The catch: one model at a time. Running a coding model and an image generator simultaneously will swap.

### 48GB (upgrade sweet spot)

- **Qwen 2.5 Coder 32B** (Q4) — ~20GB, 92.7% HumanEval, matches GPT-4o on code benchmarks
- **Gemma 3 40B** (Q4) — ~24GB, 128K context, great for content generation
- **FLUX.1 Schnell** — ~16GB, high-quality image gen in 30-60s

You can run a coding model *or* a creative model with headroom. Not both simultaneously.

### 64GB (the real sweet spot)

- **Llama 3.3 70B** (Q4) — ~40GB, with ~20GB headroom for OS, apps, and a second model
- Two models loaded at once — coding + creative, no swapping
- FLUX.1 Dev alongside an active LLM

The jump from 48GB to 64GB is only ~$400 on Apple&apos;s configurator but unlocks 70B models and multi-model workflows. This is the tier where local AI stops feeling like a compromise.

## The models that matter in 2026

### For coding

**Qwen 2.5 Coder 32B** is the answer for most people. 128K context window, 92.7% on HumanEval, 73.7% on the Aider benchmark. It handles multi-file edits, refactoring, and test generation well.

**Qwen3 Coder 30B-A3B** is the wildcard — a Mixture of Experts model where only 3.3B parameters are active per token. It needs ~12GB of RAM despite being a &quot;30B&quot; model. If you&apos;re RAM-constrained, this is the one to watch.

For autocomplete specifically, **Qwen 2.5 Coder 7B** at Q8 quantization is fast enough for tab completion and fits alongside larger models.

### For creative text

**Llama 3.3 70B** (Q4) for maximum quality if you have the RAM. **Gemma 3 40B** for 128K context at lower memory cost. Both handle structured JSON output — critical if your app needs parseable responses, not just prose.

Ollama supports constrained JSON output natively now. You can pass a JSON schema in the API call and the model&apos;s output will conform to it. This matters more than benchmark scores for production use.

### For image generation

On Apple Silicon, **Draw Things** is the fastest runtime — 25% faster than mflux for FLUX models, with optimized Metal FlashAttention 2.0. For Stable Diffusion, **Mochi Diffusion** uses Core ML and the Neural Engine, running at ~150MB memory.

Reality check: Apple Silicon is 2-4x slower than NVIDIA GPUs for image generation. If you&apos;re generating dozens of images per session, this is where a Linux GPU box pays for itself.

## The tools that wire it together

The model is only half the equation. You need the tooling layer:

| Layer | Tool | What it does |
|-------|------|-------------|
| Model runtime | **Ollama** | Serves models via OpenAI-compatible API. One command to download and run any model. |
| CLI coding agent | **Aider** | Git-native AI pair programmer. Applies diffs, understands repo context. Connects to Ollama. |
| VSCode integration | **Continue.dev** | Model routing — small fast model for autocomplete, big model for chat/reasoning. |
| Image generation | **Draw Things** or **ComfyUI** | Native macOS app or node-based workflow. Both support FLUX and SDXL. |
| Chat interface | **Open WebUI** | ChatGPT-style web UI for any Ollama model. Docker one-liner. |

The key insight: **Ollama&apos;s OpenAI-compatible API means your code barely changes.** If you&apos;re already calling `https://api.groq.com/openai/v1/chat/completions`, switching to `http://localhost:11434/v1/chat/completions` is a one-line change. Same request format, same streaming SSE response format.

## Hardware costs (verified March 2026)

| Option | Config | Price | Best for |
|--------|--------|-------|----------|
| Mac Mini M4 Pro 48GB | 14C/20G, 1TB | $1,999 | Running 32B coding models comfortably |
| Mac Mini M4 Pro 64GB | 14C/20G, 1TB | ~$2,399 | 70B models + multi-model workflows |
| Used RTX 3090 | 24GB VRAM | $650-840 | Cheapest path to serious VRAM ($33/GB) |
| Linux GPU box | Workstation + 3090 | $1,200-2,000 | Fast inference, image gen |
| Mac Studio M3 Ultra | 192GB unified | $5,499 | Overkill, but no compromises |

If you already have a 24GB Mac, selling it covers $400-500 toward the upgrade. Net cost for the 64GB sweet spot: around $1,900-2,000.

Note on used GPU pricing: tariffs are expected to push used RTX 3090 prices up 10-20% in Q1-Q2 2026. If you&apos;re going the Linux route, sooner is cheaper.

## What&apos;s not ready yet

Honest assessment. Skip this section if you only want good news.

**Local coding assistants are at maybe 40-60% of Claude Code capability for complex tasks.** Single-file edits, refactoring, debugging, test writing — fine. &quot;Build me a full authentication system across 12 files in one session&quot; — not fine. Qwen 2.5 Coder 32B matches GPT-4o on benchmarks, but benchmarks aren&apos;t multi-file architectural reasoning.

**Image generation on Apple Silicon is slow.** FLUX.1 Schnell takes 30-60 seconds per image on M4 Pro. If your workflow generates 20+ images per session, you&apos;ll feel it. A $700 used RTX 3090 cuts that to 5-10 seconds.

**Package managers need internet.** npm, pip, Homebrew — they all phone home. You can cache with Verdaccio (npm) or pre-download bottles (Homebrew), but it&apos;s maintenance overhead you don&apos;t have today.

**Documentation and search are the silent dependency.** Stack Overflow, MDN, Apple Developer docs — you don&apos;t realize how often you reach for them until you can&apos;t. Pre-downloading docs is possible but tedious. This might be the hardest thing to replace.

## The framework, not the answer

The specific models and prices in this post will age. The framework won&apos;t:

1. Audit every cloud dependency
2. Identify the local replacement with specific hardware requirements
3. Price the hardware honestly
4. Be honest about what doesn&apos;t work yet
5. Update the audit every time you add a new dependency

I keep a living document that gets updated every time I touch the stack. When a dependency changes, the offline alternative gets re-evaluated. It&apos;s not a one-time exercise — it&apos;s a habit.

The goal isn&apos;t to go offline tomorrow. It&apos;s to know that you *could*.

---

*This is Part 1 of the Off the Grid series. Next up: actually running the dev workflow offline for a week and documenting what breaks.*</content:encoded><category>local-ai</category><category>apple-silicon</category><category>homelab</category><category>offline</category></item><item><title>Self-Hosting Remote VSCode with Cloudflare Tunnel and Authentik SSO</title><link>https://charlieseay.com/blog/remote-vscode-cloudflare-authentik/</link><guid isPermaLink="true">https://charlieseay.com/blog/remote-vscode-cloudflare-authentik/</guid><description>How I set up code-server behind a Cloudflare Tunnel and Authentik forward auth so I can write code from any device — including my iPad.</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>Working remotely on lab projects meant a VPN or SSH keys on every device. code-server fixed that — full VS Code in a browser tab, locked behind Google SSO, running 24/7 on the Mac Mini. Here&apos;s how it works.

![VS Code running in a browser tab with Projects, vault, and Claude Code chat panel open](/blog/code-server/code-server-browser.png)

## What&apos;s in the stack

- **[code-server](https://github.com/coder/code-server)** by [Coder](https://coder.com/) — VS Code in the browser, packaged as a Docker image by [LinuxServer.io](https://www.linuxserver.io/)
- **[Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)** — outbound-only tunnel, no open ports on the router
- **[Authentik](https://goauthentik.io/)** by [Authentik Security](https://github.com/goauthentik/authentik) — self-hosted SSO with Google OAuth2
- **[Nginx Proxy Manager](https://nginxproxymanager.com/)** by [jc21](https://github.com/NginxProxyManager/nginx-proxy-manager) — reverse proxy that enforces forward auth on every request

## Why not just use SSH?

SSH works, but requires a client and keys on every device, and you lose the full editor experience. code-server gives you extensions, an integrated terminal, and Claude Code in any modern browser. Once it&apos;s running and proxied, it works on iPad just as well as a laptop.

## How the auth flow works

Every request to `code.yourdomain.com` goes through this chain:

```
Browser → Cloudflare Tunnel Edge → Nginx Proxy Manager → Authentik outpost check
                                                         ↓ (if authenticated)
                                                     code-server
```

Nginx Proxy Manager uses `auth_request` to check every request against Authentik&apos;s embedded outpost. If you&apos;re not authenticated, you land on the Authentik login page — in this case, a Google OAuth2 prompt.

![Authentik login — &quot;Welcome to SeaynicNet&quot; with Continue with Google](/blog/code-server/authentik-login.png)

## Deployment

### 1. The compose file

```yaml
services:
  code-server:
    image: lscr.io/linuxserver/code-server:latest
    container_name: code-server
    environment:
      - PUID=501
      - PGID=20
      - TZ=America/Chicago
      - PASSWORD=${CODE_SERVER_PASSWORD}
      - SUDO_PASSWORD=${CODE_SERVER_PASSWORD}
      - DEFAULT_WORKSPACE=/config/workspace
    volumes:
      - /your/config:/config
      - /your/projects:/config/workspace/Projects
    ports:
      - 8484:8443
    restart: unless-stopped
```

Put real credentials in a `.env` file alongside the compose file:

```
CODE_SERVER_PASSWORD=your-password-here
```

Then `chmod 600 .env` so only your user can read it.

**Important:** always use `docker compose up -d` — not `docker restart` — when you change env vars. `docker restart` reuses the original environment from when the container was first created. `docker compose up -d` re-reads the compose file and `.env`.

### 2. Nginx Proxy Manager config

Create a proxy host for `code.yourdomain.com`:
- Forward Hostname: your server&apos;s local IP (not `localhost`)
- Forward Port: `8484`
- **WebSockets Support: ON** — code-server won&apos;t work without this

**Force SSL must be OFF.** Cloudflare terminates TLS at the edge and sends plain HTTP to NPM. If you enable Force SSL in NPM, it will redirect to HTTPS and get redirected again — an infinite loop. NPM receives HTTP and your browser sees HTTPS because Cloudflare handles the certificate.

### 3. Authentik forward auth

After NPM creates the proxy host config file, patch it to add the Authentik blocks before the main `location /` block:

```nginx
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;

location /outpost.goauthentik.io {
    proxy_pass http://your-server-ip:9010/outpost.goauthentik.io;
    proxy_set_header Host $http_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;
}

location @goauthentik_proxy_signin {
    internal;
    return 302 /outpost.goauthentik.io/start?rd=https://$http_host$request_uri;
}
```

The `rd=https://` redirect URL is what brings you back to code-server after logging in. Without it, Authentik sends you to its own dashboard instead of where you were going.

Reload nginx after editing the conf: `docker exec nginx-proxy-manager nginx -s reload`

## Three things I got wrong the first time

**Credentials not updating after editing compose.** Edited the compose file, ran `docker restart`, and the old password still worked. The fix is always `docker compose up -d`.

**YAML special characters in passwords.** A password containing `&amp;` broke the compose file — YAML treats `&amp;` as an anchor. Fix: move credentials to a `.env` file and reference them as `${VAR}`.

**Force SSL redirect loop.** Enabled Force SSL in NPM because the site &quot;should be HTTPS.&quot; Every request looped forever because NPM kept redirecting to HTTPS while Cloudflare kept sending plain HTTP. Remove the Force SSL block — Cloudflare handles TLS end to end.

## Extensions on code-server

code-server uses the [Open VSX Registry](https://open-vsx.org/), not the Microsoft marketplace. Most extensions are available, but a few Microsoft-owned ones aren&apos;t. The ones I run:

```bash
for ext in \
  anthropic.claude-code \
  llvm-vs-code-extensions.lldb-dap \
  mechatroner.rainbow-csv \
  ms-azuretools.vscode-containers \
  ms-python.debugpy \
  ms-python.python \
  swiftlang.swift-vscode \
  tomoki1207.pdf; do
  docker exec -u abc code-server /app/code-server/bin/code-server --install-extension &quot;$ext&quot;
done
```

Note: `github.copilot-chat` and `ms-python.vscode-pylance` are not on Open VSX and can&apos;t be installed.

## Authenticating Claude Code in a container

The Claude Code extension needs Node.js to run its CLI agent. The LinuxServer.io code-server image doesn&apos;t include it, so you need to install it — and make it survive container recreations.

Create a startup script at `config/custom-cont-init.d/install-claude.sh`:

```bash
#!/bin/bash
if ! command -v node &amp;&gt; /dev/null; then
  apt-get update -qq &amp;&amp; apt-get install -y -qq nodejs npm &gt; /dev/null 2&gt;&amp;1
fi
if ! command -v claude &amp;&gt; /dev/null; then
  npm install -g @anthropic-ai/claude-code &gt; /dev/null 2&gt;&amp;1
fi
```

LinuxServer.io images run anything in `custom-cont-init.d/` at startup, so Node and Claude CLI persist across `docker compose up -d` cycles.

**The billing gotcha:** Claude Code supports two authentication methods — OAuth login (uses your Claude Pro/Max subscription) and API key (pay-as-you-go billing against your Anthropic API account). In a headless container, the OAuth browser flow doesn&apos;t work because the callback can&apos;t reach the container.

The workaround: if you&apos;re already authenticated on a desktop machine, your OAuth token lives in the system keychain. On macOS:

```bash
security find-generic-password -s &quot;Claude Code-credentials&quot; -w
```

That returns a JSON blob with an `accessToken` field — an `sk-ant-oat` prefixed token. Set that as your `ANTHROPIC_API_KEY` environment variable in the container&apos;s `.env` file. Despite the name, OAuth tokens route through your subscription, not pay-as-you-go.

```
ANTHROPIC_API_KEY=sk-ant-oat01-your-token-here
```

**Important:** `sk-ant-api` tokens bill against your API account. `sk-ant-oat` tokens bill against your subscription. If you generate a key from `console.anthropic.com`, that&apos;s an API key and you&apos;ll be charged per token. The OAuth token from the keychain uses your existing plan.

## Giving Claude deeper access without opening new attack surface

Once Claude Code is running in the container, the natural next question is: can it actually manage infrastructure? Out of the box, the answer is mostly no. It can read and write files in the workspace, but it can&apos;t run Docker commands, push to GitHub, or use any custom skills you&apos;ve built.

The naive fixes — mount the Docker socket, add SSH keys, install tools with root — each add real risk. Here&apos;s what I did instead.

### GitHub CLI without sudo

The container doesn&apos;t have `gh` installed and you can&apos;t `sudo apt install` without a password prompt. The workaround is installing the binary directly to a directory you own:

```bash
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep &apos;&quot;tag_name&quot;&apos; | cut -d&apos;&quot;&apos; -f4 | sed &apos;s/v//&apos;)
curl -sL &quot;https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_arm64.tar.gz&quot; \
  -o /tmp/gh.tar.gz
mkdir -p /config/bin
tar -xz -f /tmp/gh.tar.gz -C /tmp
cp /tmp/gh_${GH_VERSION}_linux_arm64/bin/gh /config/bin/gh
echo &apos;export PATH=&quot;/config/bin:$PATH&quot;&apos; &gt;&gt; /config/.bashrc
```

Since `/config` is a persistent volume, the binary survives container recreations. Then `gh auth login` to authenticate — use a fine-grained personal access token scoped to only the repos Claude needs, rather than full OAuth. Smaller blast radius if the container is ever compromised.

### Docker access via Portainer API

Mounting the Docker socket into a container is effectively handing out root on the host. Anyone who can talk to the socket can escape the container entirely. Don&apos;t do it.

If you&apos;re already running Portainer, it exposes a REST API that Claude can call with a token. Same outcome — create containers, inspect services, pull images — but the token is scoped, auditable, and revocable without touching the container.

In Portainer: **Account Settings → Access Tokens → Add access token**. Give it a name like `claude-code-server`.

Store the token as an environment variable in the container&apos;s `.env` file:

```
PORTAINER_URL=https://portainer.yourdomain.com
PORTAINER_TOKEN=your-token-here
```

Test from inside the container:

```bash
curl -s -H &quot;X-API-Key: $PORTAINER_TOKEN&quot; $PORTAINER_URL/api/endpoints
```

Claude can now call the Portainer API directly without any elevated host access.

### Skills and agents

Custom Claude Code skills (slash commands) live in `~/.claude/commands/` on whatever machine Claude Code is running on. Inside the container, that resolves to `/config/.claude/commands/` — and that directory doesn&apos;t exist by default.

The fix is a single volume mount in the compose file that points the container&apos;s commands directory at the one on the host:

```yaml
volumes:
  - /your/config:/config
  - /your/projects:/config/workspace/Projects
  - ~/.claude/commands:/config/.claude/commands:ro   # add this
```

The `:ro` flag mounts it read-only — Claude can use the skills but can&apos;t modify them from inside the container. Restart the container and the skills show up in the next session.

Agent definitions stored as vault notes are already accessible since the vault is mounted as a workspace folder. No extra setup needed there.

## End result

Authenticated VS Code at `https://code.yourdomain.com` — works on iPad, iPhone, any laptop. Sessions persist between visits, and extensions, settings, and workspace survive image updates since they live in the config volume.

The Obsidian vault is mounted as a workspace folder too, so notes edited in the browser sync to iCloud immediately.

![code-server open on iPad at code.seaynicroute.com](/blog/code-server/code-server-ipad.png)</content:encoded><category>homelab</category><category>docker</category><category>cloudflare</category><category>devops</category></item></channel></rss>