Paul's Programming Notes PostsRSSGithub

Is CloudNativePG ready to replace Aurora or AlloyDB?

CloudNativePG is a Kubernetes operator for running PostgreSQL. It joined the CNCF Sandbox in January 2025, applied for Incubation and added PostgreSQL 18 support later that year, and shipped v1.30 in June 2026. It’s used by some big companies like IBM, Google Cloud, and Microsoft Azure, and it looks solid, though I haven’t run it myself. I’ve been weighing whether it’s worth advocating a swap from a plain Aurora or AlloyDB database onto it, and how much work you’d be taking on.

The pitch for Aurora and AlloyDB is that they take storage off your plate and let you scale compute on its own. Storage grows automatically with no downtime, up to 128 TiB, and is replicated six ways across three availability zones, so a write survives losing an entire zone and you never plan capacity or schedule a resize. AlloyDB takes the same approach on its own disaggregated storage and adds a columnar engine for analytics. CloudNativePG doesn’t work that way. It runs classic shared-nothing streaming replication, where each instance is a full Postgres node with its own PersistentVolumeClaim, and durability comes from replicating between instances rather than from a shared storage layer.

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg
spec:
  instances: 3                 # one primary, two streaming replicas
  storage:
    storageClass: local-nvme   # benchmark this before you trust it
    size: 200Gi
  walStorage:                  # WAL on its own volume
    storageClass: local-nvme
    size: 40Gi
  replicationSlots:
    highAvailability:
      enabled: true
  postgresql:
    parameters:
      shared_buffers: "4GB"    # tuning is yours now
    synchronous:               # opt in for RPO=0 (replication is async by default)
      method: any              # any = quorum-based
      number: 1                # standbys that must confirm each commit

That model works, but the volume is now your job. The docs recommend local NVMe for serious workloads and tell you to benchmark with fio and pgbench first, because network-attached storage can wreck Postgres latency. And there’s no auto-scaling, so you size the PVC up front and grow it later only if your CSI driver supports expansion.

Each instance keeps its own copy of the data, so with local volumes a failed node takes its data with it and the operator re-creates that instance by cloning a fresh copy from the primary, a slow rebuild for a large database. That only protects against a zone outage if the replicas are in other zones, which takes pod anti-affinity and topologySpreadConstraints to arrange, with the usual advice to run nodes in multiples of three, one per zone. The operator promotes a replica automatically when the primary fails, but replication is asynchronous by default, so that promotion can drop the last few committed transactions. Synchronous replication (method: any quorum, above) gets you back to RPO=0, and it makes the CAP tradeoff explicit. With the default dataDurability: required, writes pause when the operator can’t reach enough synchronous standbys, so a bad enough zone outage costs availability to preserve consistency. Aurora and AlloyDB make that same call at the storage layer and keep taking writes through an AZ failure. Cross-region means running a separate replica cluster fed by streaming or WAL shipping, with no automatic failover between clusters.

The operational pieces are all yours too. Continuous backup and point-in-time recovery go to object storage through the barman-cloud plugin, but you configure it, set the retention, and test that a restore actually works. Connection pooling is a Pooler resource running PgBouncer that you deploy and size. Monitoring is Prometheus metrics the operator exposes, wired into whatever stack you already run. Minor-version upgrades are rolling pod restarts you trigger.

So is it worth it? If you’re paying for Aurora mainly to avoid thinking about Postgres, self-hosting probably isn’t worth it. If you already have the Kubernetes and Postgres chops, live in GitOps, want off the managed-service markup, or need something the managed services won’t give you (specific extensions, exact versions, multi-cloud portability, no lock-in), it looks worth a serious trial. Either way I’d start on something smaller than a tier-1 database and prove out storage, backups, and failover first.

Switching my local LLM to Qwen 3.6, a 35B Mixture-of-Experts model, on a 16 GB GPU

A few months ago I wrote about switching Open WebUI from Ollama to llama.cpp for Qwen 3.5. I’m still using the same RTX 5060 Ti with 16 GB of VRAM, but I swapped the dense 9B for Qwen3.6-35B-A3B, a Mixture-of-Experts (MoE) model. A 35B model usually wouldn’t fit on a 16 GB card without MoE.

A dense model uses every parameter for every token, so the weights have to fit in VRAM or throughput tanks. On this card that capped me around 14B. MoE models have many expert sub-networks but only use a few per token. Qwen3.6-35B-A3B is 35B total but only ~3B active. The idle experts can sit in system RAM instead of VRAM, pulled onto the GPU when needed for a small speed hit. llama.cpp does this with --n-cpu-moe, which keeps the top N layers’ experts on the CPU.

Staying within the 16 GB budget took some more tuning. I set LLAMA_ARG_N_CPU_MOE=8, which freed about 2 GB of VRAM for context. Raise it if the model OOMs on load, lower it if you have VRAM to spare. I used a dynamic UD-Q3_K_M quant (about 15 GB) and lowered BATCH_SIZE and UBATCH from the 9B’s values to leave room for a 64K KV cache. Flash attention and a q8_0 KV cache save the rest, same as before.

Turning thinking mode off took two flags. LLAMA_ARG_THINK_BUDGET=0 (reasoning-budget 0) alone didn’t stop it, a known issue on the hybrid Qwen models. I was still watching it think in circles for several minutes without getting anything done. The other was enable_thinking=false in the chat template (LLAMA_ARG_CHAT_TEMPLATE_KWARGS), with jinja on. I also set the sampling to Qwen3.6’s non-thinking defaults (temp 0.7, top_k 20, top_p 0.8).

For serious work I still use a hosted frontier model, but this is handy for local jobs like redacting or cleaning text before it goes to the cloud.

Minimal two-container compose to get mostly set up:

services:
  llama-server:
    image: ghcr.io/ggml-org/llama.cpp:server-cuda-b9592
    restart: unless-stopped
    environment:
      # Pin the model cache to the mounted volume so a container recreate
      # doesn't re-download the weights.
      - LLAMA_CACHE=/root/.cache/llama.cpp
      # Auto-downloads from HuggingFace on first run.
      - LLAMA_ARG_HF_REPO=unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q3_K_M
      - LLAMA_ARG_N_GPU_LAYERS=99      # all layers to GPU...
      - LLAMA_ARG_N_CPU_MOE=8          # ...except the top 8 layers' experts, kept in system RAM
      - LLAMA_ARG_CTX_SIZE=65536       # 64K context, realistic for a 35B MoE in 16 GB
      - LLAMA_ARG_N_PARALLEL=1         # single user, one KV cache slot
      - LLAMA_ARG_FLASH_ATTN=1         # big KV cache VRAM savings
      - LLAMA_ARG_CACHE_TYPE_K=q8_0    # halve KV cache memory vs FP16
      - LLAMA_ARG_CACHE_TYPE_V=q8_0
      - LLAMA_ARG_BATCH_SIZE=2048      # lowered from 4096 to leave VRAM for the bigger model
      - LLAMA_ARG_UBATCH=512           # lowered from 2048 for the same reason
      - LLAMA_ARG_JINJA=1              # correct chat template + tool calling
      # Non-thinking mode. On the Qwen3 hybrid models, reasoning-budget 0 alone kept
      # emitting think blocks; enable_thinking=false is what stopped it, so set both.
      - 'LLAMA_ARG_CHAT_TEMPLATE_KWARGS={"enable_thinking":false}'
      - LLAMA_ARG_THINK_BUDGET=0       # reasoning-budget 0
      - LLAMA_ARG_TEMP=0.7             # Qwen3.6 non-thinking sampling defaults
      - LLAMA_ARG_TOP_K=20
      - LLAMA_ARG_TOP_P=0.8
      - LLAMA_ARG_MIN_P=0
      - LLAMA_ARG_PORT=11434
      - LLAMA_ARG_HOST=0.0.0.0
    volumes:
      - ./models:/root/.cache/llama.cpp
    ports:
      - "11434:11434"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

  open-webui:
    image: ghcr.io/open-webui/open-webui:v0.9.6
    restart: unless-stopped
    ports:
      - "3000:8080"
    environment:
      - ENABLE_OLLAMA_API=false
      - OPENAI_API_BASE_URLS=http://llama-server:11434/v1
      - OPENAI_API_KEYS=no-key
    volumes:
      - ./open-webui:/app/backend/data
    depends_on:
      - llama-server

The best-value thin clients with a PCIe slot in 2026

I was picking hardware for an OPNsense router and needed a real PCIe slot, which most cheap low-power boxes don’t have. A slot lets you add a network card with SFP+ or more ports, and swap it later for something else (like a GPU).

The thin clients I wrote about last time don’t have one. The Wyse 5070 and OptiPlex 3000 both have an M.2 A/E key slot, good for a 2.5GbE adapter or a Coral TPU but not an SFP+ or a quad-port NIC.

A few corporate thin clients do have a full-size slot:

ModelCPUPCIe slotUsed price
HP t730AMD RX-427BB (4C)half-height x16 mechanical, x8 electrical~$100-120
HP t740AMD Ryzen V1756B (4C/8T)half-height x16 mechanical, x8 electrical, Gen3~$150-170
Dell Wyse 5070 ExtendedCeleron J4105 / Pentium J5005half-height slot in the thicker “Extended” chassis~$90-130

The HP t730 and t740 are the well-known ones, both popular pfSense/OPNsense boxes with a half-height x16-mechanical, x8-electrical slot. The Wyse 5070 comes in two sizes. The slim one has only the M.2 slot. The thicker Extended chassis adds a half-height PCIe slot.

The slot is half-height, so you’ll want a low-profile card, and its power is limited (roughly 35W on the t740), enough for a NIC but only a low-power GPU.

The other route is a Topton or CWWK mini PC, which usually skips the slot and solders on the NICs instead, often four 2.5GbE ports or a couple of SFP+ cages. Handy if those ports are what you need, but you’re stuck with them, with no way to swap in a specific card.

A dead man's switch for a single-host monitoring stack

I run Prometheus, Alertmanager, and Grafana on a single mini PC, which has an obvious blind spot: if that box goes down, nothing can alert me, because the thing that sends alerts is the thing that’s down. Prometheus once sat dead for two days before I noticed, and only because a dashboard had gone blank.

The fix is a dead man’s switch: an alert that fires constantly, routed to an outside service that complains when it stops arriving. Every other alert fires when something breaks; this one fires all the time, and silence is the failure signal.

The rule is just vector(1), which is always true:

# alerts/meta.yml
- alert: Watchdog
  expr: vector(1)
  labels:
    severity: none

kube-prometheus-stack ships the same alert under the same name. Then route that one alert away from Telegram and into a webhook:

routes:
  - matchers:
      - alertname = "Watchdog"
    receiver: deadmansswitch
    group_wait: 0s
    repeat_interval: 5m   # re-ping every 5 minutes

receivers:
  - name: deadmansswitch
    webhook_configs:
      - url: https://hc-ping.com/<uuid>
        send_resolved: false

The webhook target is a healthchecks.io check. Alertmanager pings it every five minutes while the pipeline is healthy; I set the check to a ~10 minute period with a ~5 minute grace, so a real outage pages me within about 15 minutes, from a service that isn’t on my network.

Two details that bit me: the ping URL lives in my git-ignored alertmanager.yml (low-sensitivity, like the Telegram chat ID), and send_resolved: false so the “resolved” call doesn’t muddy the heartbeat.

The same trick works for any scheduled job: have the script curl a healthchecks.io ping URL on success, and let the absence page you.

Replacing my Raspberry Pis with used thin clients

Raspberry Pis have gotten expensive enough that I’ve started replacing them with used corporate thin clients. These two came off eBay: a Dell Wyse 5070 (Celeron J4105) for $43 (+$15 for a power adapter it didn’t include), and a Dell OptiPlex 3000 thin client (Pentium Silver N6005) for $84. Both are fanless, both idle at 5W or less, and both cost a fraction of a Pi 5.

A Pi 5 16GB lists at $299.99 these days, and adding a power supply, a case, an SD card, and an NVMe HAT pushes it well past what I paid for either box. The OptiPlex came with 16GB of RAM and its power adapter for $84, with an M.2 slot for a real NVMe drive. Jeff Geerling called the hobbyist SBC market “dying, or at least on life support” in April 2026, and for a low-power box that sits in a closet the thin client is just better value now. However, the OptiPlex’s N6005 is slower than a Pi 5 in Geekbench 6, 544/1421 to the Pi’s 764/1604.

These boxes are old (the Wyse 5070 can be 8+ years old, the OptiPlex 3000 4+), so run memtest86+ before you trust one. The Wyse is why I say that: both its 4GB SK Hynix sticks failed memtest, so I swapped in one of the OptiPlex’s 8GB sticks.

My favorite thing about the Wyse 5070 is the USB-C port. It normally runs headless, but for setup and troubleshooting I can plug it into a USB-C hub and get video and USB over one cable.

The OptiPlex 3000 is the more annoying of the two. No USB-C, and its M.2 slot officially only takes a short 2230 SSD. A full-length 2280 fits, but I had to desolder a little standoff to make room. I tried to dodge the soldering iron with a Sintech M.2 extender cable first, and that was a mistake: the extra length wrecked PCIe signal integrity and dmesg filled with correctable errors:

AER: Correctable error message received
PCIe Bus Error: severity=Correctable, type=Physical Layer

What the OptiPlex has going for it is the newer processor. These make a capable little web server: with a low-latency home connection you can expose Docker containers to the public internet through a Cloudflare Tunnel with no port forwarding or static IP. (Other people run the 5070 as a fanless Linux home server too.)

Why I moved from Hubitat to Home Assistant

I ran a Hubitat hub for years and liked it. Somewhere along the way I stood up Home Assistant too, because it handled a few things better (some Aqara sensors, mostly), and for a long time I was happy running both.

Running two hubs stayed painless thanks to the Hubitat integration for Home Assistant, a HACS add-on that pulls Hubitat devices into HA over Hubitat’s Maker API. Expose a device in the Maker API and it shows up in Home Assistant automatically, state and all, so HA could see everything while Hubitat still ran the radios.

What changed recently is the tooling. LLMs are genuinely good at writing and fixing Home Assistant automations, because an HA automation is plain YAML in a file and there are years of community examples the models have already learned from. Debugging one used to be an afternoon; now it’s a quick back-and-forth. So I’ve been repairing automations I’d let sit broken for years, and getting more ambitious with new ones now that the cost of getting them working has dropped.

That’s the real reason Home Assistant wins for me now: the config lives in code. I keep automations.yaml in git, hand the whole file to an assistant to review or extend, and get back a diff I can read and commit. The one wrinkle is that HA’s GUI editor rewrites the whole file on every save, so I match its serializer (PyYAML’s CSafeDumper, block style, keys left unsorted) to keep a save down to a one-line diff, and I put rationale in each automation’s description field since comments don’t survive. On Hubitat the logic lived behind a UI I couldn’t diff or point a tool at.

Two more things pushed me to go all-in. Monitoring Home Assistant itself with Grafana is great, and I’d rather run one system than keep two in sync: renaming an entity across both hubs is clunky, and one source of truth is simpler. I picked up the Home Assistant Connect ZWA-2, Home Assistant’s official Z-Wave adapter with Z-Wave Long Range support, to move the last Z-Wave devices over, a real range upgrade from the old Hubitat box.

A few sensors are still on Hubitat, and it served me well for years. But with everything in code and one hub to manage, I’m not going back.

Switching from pre-commit to prek

I moved my repos from pre-commit to prek, a reimplementation of the same framework in Rust. It reads your existing .pre-commit-config.yaml unchanged, so the switch is mostly a no-op. Two things sold me: it installs as a single binary with no Python of its own (uv tool install prek, or a standalone script), and for language: python hooks it builds the hook environments with uv instead of pip and virtualenv, which is a lot faster on a cold cache.

“Drop-in” came with one wrinkle. prek’s YAML parser is stricter than pre-commit’s, and it rejected two hook manifests that pre-commit had been quietly accepting for years. Both turned out to be real bugs in the upstream hooks.

The first was a duplicate key. The gruntwork-io/pre-commit version I was pinned to, v0.1.20, shipped a golangci-lint hook that declared language twice:

- id: golangci-lint
  entry: hooks/golangci-lint.sh
  language: script
  language: script   # duplicate
  files: \.go$

pre-commit parses with PyYAML, which silently keeps the last of two duplicate keys and moves on. prek’s Rust parser treats a duplicate mapping key as an error and refuses to run. It’s fixed upstream in v0.1.30 (gruntwork PR #134), so bumping the hook version cleared it.

The second was deprecated stage names. The pre-commit-hooks version I was pinned to, 4.4.0, still used the old git-stage spelling:

stages: [commit, push]           # deprecated
stages: [pre-commit, pre-push]   # current

pre-commit renamed the commit stage to pre-commit and push to pre-push a while back and started warning on the old names; newer tooling rejects them outright. pre-commit-hooks uses the modern names in 6.0.0, so upgrading to it sorted it.

Neither was prek’s fault. It just stopped tolerating manifests that were already wrong.

Once everyone on the repo is on prek, you might not need the pre-commit-hooks repo at all. prek ships Rust-native versions of the common checks (trailing whitespace, end-of-file fixer, YAML and JSON validation) that you pull in with repo: builtin instead of cloning and pinning the upstream hooks. Just note that repo: builtin only works under prek, so it’s for repos where you’ve moved the whole team over and don’t need the config to keep running under stock pre-commit.

The reason I bother with any of this: I run the exact same hooks locally and in CI, through j178/prek-action. If a formatter or a lint rule is going to fail, I want it fixed in the commit, not after a five-minute CI round-trip and a “fix lint” follow-up commit. Enforcing it across a whole team kills a lot of that churn.

The catch is that hooks have to stay fast, because they run on every commit. Formatters, linters, a YAML syntax check: fine. The full test suite or Playwright browser tests: no. Those go in CI, where slow is acceptable and the same prek config runs --all-files anyway.

The Zyxel XGS1210-12 is the best cheap OpenWrt 2.5G switch right now

I run OpenWrt as the firmware on my core switch, a Zyxel XGS1210-12: eight gigabit ports, two 2.5GbE copper ports, and two SFP+ cages that do 10G, for around $120. If you want a cheap, multi-gig managed switch that runs open-source firmware and still does VLANs and the rest, I think it’s the best option right now. It’s on the hardware list in the OpenWrt Beginner’s Guide, which is where I’d check for current recommendations since this moves quickly.

For me the appeal is owning the hardware outright, more than any feature list. With open firmware I can see exactly what the switch runs, test a fix myself when I hit a bug, and send a patch upstream if it comes to that. Closed gear leaves you one update away from changes that suit the vendor more than you, and for something as tied to privacy and security as networking equipment, the transparency is worth it.

UniFi is the obvious alternative, and credit where it’s due: its UI and single-pane-of-glass view of a whole network are genuinely nicer, so going the OpenWrt route is hard mode. But it costs a lot more. A comparable UniFi switch with 2.5G and a 10G SFP+ uplink, the Pro Max 16, runs a few hundred dollars ($399 with PoE, less without) versus $120 here, and that adds up fast across several switches. The open-source enterprise options don’t help on price either: SONiC, DENT, and Cumulus all need $1,000-plus whitebox gear.

Under the hood it’s a Realtek RTL9302, and getting the 2.5G copper ports working was genuinely hard. The PHY spoke a proprietary Realtek protocol the Linux kernel couldn’t drive, and the fix took getting proper support upstream so it could switch modes by link speed. The whole saga is in issue #19640; the breakthrough landed in PR #19843 in August 2025, and the rev B1 board got its fix in PR #21605 in January 2026. That work came from Birger Koblitz (who started OpenWrt’s Realtek port), Tobias Schramm, Markus Stockhausen, Jan Hoffmann, Jonas Jelonek, and Sander van Heule. I’m surely forgetting people, so check the linked PRs and issue thread for the full list.

None of it came with a datasheet. Realtek doesn’t publish one, so the whole thing was reverse-engineered from GPL source dumps and a lot of register poking. Sander van Heule documents the internals at svanheule.net and even built a web tool for browsing these chips’ registers. People doing that for free, with no documentation, is the reason a $120 switch can run mainline Linux at all. I appreciate it a lot.

OpenWrt itself is worth appreciating too. It’s a remarkably flexible Linux build, and with all sorts of tricks to squeeze it down, it’ll run a full, configurable network OS on devices with only a few megabytes of flash. Getting real Linux into that little space is the cool part.

A couple of things to flag. There’s no PoE on this model, so APs and cameras need injectors or a separate PoE switch. And the 2.5G work is on the snapshot builds rather than the stable release for now, so flash a recent snapshot that matches your board revision (mine’s the B1). As of writing that’s r35109-f5d928e52a. Beyond that it’s been solid for me, and for a cheap, open, multi-gig managed switch it’s the one I’d get.

Claude Code - Experimenting With Dev Containers and Permission Allowlists

Updated 2026-07-11: reworked around workspace trust, which was the real reason my allowlist wasn’t taking effect.

I run Claude Code, and its VS Code extension, inside dev containers. The reason is isolation. Editor extensions and the toolchains a project pulls in have been a real supply-chain vector lately, and a dev container keeps a compromised one off my host: the extension, the agent, and everything npm or uv installs all live inside the container, not on my machine.

That isolation changes how I think about permissions. Because the blast radius is the container and not my laptop, I’m comfortable letting the agent do far more inside it than I would on bare metal.

The container is a small file. The Claude Code dev container feature installs the CLI and the extension into the container, and I run as a non-root user. I also mount a named volume at ~/.claude so plugins and login state persist across rebuilds:

{
  "name": "project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
  "mounts": [
    "source=claude-code-shared,target=/home/vscode/.claude,type=volume"
  ],
  "remoteUser": "vscode",
  "postCreateCommand": "bash .devcontainer/post-create.sh"
}

That volume isn’t the whole story. Claude Code keeps your login in ~/.claude.json, a $HOME file that sits outside the mounted .claude directory, so a rebuild wipes it and forces a re-login. I fixed that a while back by symlinking the file into the volume:

if [ ! -L "$HOME/.claude.json" ]; then
  [ -f "$HOME/.claude.json" ] && mv -f "$HOME/.claude.json" "$HOME/.claude/.claude.json"
  ln -sf "$HOME/.claude/.claude.json" "$HOME/.claude.json"
fi

The same file holds your per-repo workspace trust, and that turned out to matter more than the login. Claude Code won’t apply a repo’s committed allow rules until you accept its workspace-trust dialog (docs); until then it reads the rules and ignores them, so an allowlisted make test still prompts. Since trust lives in the same ~/.claude.json, the symlink already persists it, so I pre-accept it for the repo and skip the dialog. Do it right after the symlink so the write lands in the volume, and key on the exact git repo root, since trusting a parent like /workspaces doesn’t count (anthropics/claude-code#72896):

REPO_ROOT="$(git -C "$PWD" rev-parse --show-toplevel)"
node -e '
  const fs = require("fs"), f = process.env.HOME + "/.claude.json", p = process.argv[1];
  const j = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8") || "{}") : {};
  (j.projects ||= {})[p] ||= {};
  j.projects[p].hasTrustDialogAccepted = true;
  fs.writeFileSync(f, JSON.stringify(j, null, 2));
' "$REPO_ROOT"

With trust settled, the committed allowlist takes effect. I commit a wide allowlist to .claude/settings.json and turn on acceptEdits so routine file writes don’t prompt either:

{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(make test:*)",
      "Bash(make lint:*)",
      "Bash(uv run pytest:*)",
      "Bash(npm run build:*)",
      "Bash(tail:*)",
      "Bash(head:*)",
      "Bash(grep:*)",
      "Bash(git add:*)",
      "Bash(git commit:*)"
    ],
    "deny": ["Read(.env)", "Read(**/.env)"]
  }
}

It goes in .claude/settings.json, not .claude/settings.local.json, on purpose. When you click “yes, don’t ask again”, Claude Code writes the rule to the local file, and that file gets wiped on rebuild too. Commit the allowlist and it survives rebuilds and travels with the repo. tail, head, and grep are in there because Claude appends | tail or | head to a lot of commands to keep output short, and Claude Code matches each side of a pipe on its own, so the pipe target needs its own rule.

The container isn’t a free-for-all, so I keep two guardrails inside it. It still holds my code and a token, so I don’t blanket-allow uv run or npm install: uv run runs whatever follows it, and npm install <pkg> runs package install scripts, which is how a lot of npm supply-chain attacks land. I pin the inner command (uv run pytest) and leave the runners prompting. I also keep the container’s GitHub token read-only and push from the host, so a bad suggestion can’t push or open a PR on its own.

A committed allowlist still won’t cover every command, and a new one puts you back at a prompt. The option that fits the VS Code extension is auto mode, which approves routine commands with a classifier instead of a static list. The built-in Bash sandbox is the other documented path, isolating commands at the OS level, but it’s CLI-only today (anthropics/claude-code#64061), so it doesn’t reach the extension.

None of this is a hard sandbox on its own. Permission rules are enforced by Claude Code, not the OS. I’ve always been more worried about approving the one thing I shouldn’t than about the prompts themselves, and I’m still working out how to shrink the blast radius further. This is early, and I expect parts of it to look risky in hindsight.

Macvlan private mode for container L2 isolation

Macvlan is a Docker networking mode that gives each container its own MAC and IP on the network the host port is plugged into. The container shows up on the subnet as its own device, gets a DHCP lease from the router, and talks to the LAN directly instead of going through the Docker bridge.

I have a box with multiple Ethernet ports, each port on a different VLAN. Some containers sit on the port assigned to a guest-style VLAN that can only reach the internet, not other hosts on the LAN. That worked fine in the default bridge macvlan mode while each port only had one service on it. Once I added more services to the same port, I hit a problem I hadn’t thought about: containers sharing a macvlan parent in bridge mode can freely ARP and talk to each other inside the kernel, which defeats the VLAN-level isolation.

Macvlan has a private mode for exactly this. The kernel drops frames going between siblings on the same parent. Outbound traffic via the gateway still works. No switch cooperation, no extra VLANs.

docker network create -d macvlan \
  --subnet=192.0.2.0/24 \
  --gateway=192.0.2.1 \
  -o parent=eth0 \
  -o macvlan_mode=private \
  guest_private

Attach a container to it with a static IP:

services:
  app:
    networks:
      guest_private:
        ipv4_address: 192.0.2.16
networks:
  guest_private:
    external: true

One caveat: the host itself can’t reach its own containers over that interface. That’s true for any macvlan setup, not specific to private mode. For my case it’s fine since the containers just need internet access and to be reachable from other subnets through the router.

More on the modes underneath: ip-link(8) documents macvlan’s private, vepa, bridge, and passthru modes. Docker’s guide: Docker macvlan driver.