I wanted a speaker that Home Assistant can use for announcements like “Front door opened”. The easy mode is a Home Assistant Voice PE on Wi-Fi. I prefer wired when a spot has an Ethernet port, and the Voice PE is Wi-Fi only. My spot is next to one of my access points, which doubles as a small network switch.
Wired options
Wired ones are kind of rare. All I needed was an Ethernet jack, and I prefer open-source software. These are the options I found:
The WiiM Sound Lite ($229) has a 10/100 Ethernet port and an official Home Assistant integration (since 2026.4) with TTS duck-and-resume. It sets up through the WiiM Home app, runs closed-source firmware, and has no microphones. It’s the easier pick if you want a music speaker.
Sonocotta’s Esparagus Echo Duo ($45) is basically an ESP32-S3 with a pair of MAX98357 amps, an Ethernet jack and two microphones. It takes a passive speaker on its screw terminals, and Sonocotta publishes ESPHome configs for it.
Sonocotta’s Louder ESParagus ($69) has a much bigger amp, enough for a pair of passive bookshelf speakers like Micca’s MB42s ($80), which makes it the open-source music option. It’s a classic ESP32, so wake word won’t run on it.
A Waveshare ESP32-S3-ETH ($26-35) plus a MAX98357A breakout is the Echo Duo assembled by hand.
I went with the Echo Duo, which came to about $100 with the speaker below, against $59 for a Voice PE at launch, so I’m mostly paying extra for the wired connection. Sonocotta’s Tindie listing won’t ship to the US, and Elecrow was the only option I found that does, at $11.27 shipping.
The passive speaker
The Echo Duo’s outputs are already amplified, so it needs a passive speaker. Its amps are small, 1.8 W into 8 ohms at most per the MAX98357A datasheet, which is plenty for speech but won’t drive a pair of bookshelf speakers at music volume.
Mine is a Dayton Audio PS95-8 driver in geroulas’s printed desktop enclosure on MakerWorld, about $40 with the heat-set inserts. It reaches about 79 dB at three metres, slightly quieter than a smoke alarm, which has to hit 85 dB at that distance.
Board revisions and config
Rev A runs the microphones and amps on one I2S bus, which ESPHome can’t handle, so wake word only works from Rev B onward. Sonocotta ships a voice-assistant config too, so a newer board can become a wired voice satellite later, though wake word currently reads only one of its two microphones.
Sonocotta’s ESPHome configs have a folder per board revision. Mine was sold as Rev C and the repo only had Rev A and Rev B folders, so I used the Rev B media-player example, which works for audio out. Switching it to Ethernet means swapping monitoring-wifi.yaml for the commented-out ethernet-w5500.yaml in its packages list and deleting its wifi: block. The example pulls its packages from ref: main, so I pinned both refs to a commit to keep an upstream push from changing how the board behaves.
Flashing it
Skip Sonocotta’s web-installer onboarding, which assumes the board joins Wi-Fi first. Build the Ethernet config in ESPHome, flash over USB-C, then add the device in Home Assistant by IP.
The ESP32-S3 has built-in USB instead of a separate serial chip. A flash that fails with “Resource busy” and then “No such file or directory” is that USB port re-enumerating, not a bad cable. Hold IO0, tap RST, release IO0, and re-run. Both buttons are on the board, so the case has to come off.
Reserve the IP in DHCP against its MAC, since the config sets no static IP.
It needs USB-C power as well as Ethernet, since there’s no PoE.
The announce script
Sonocotta’s media-player package waits 0.75 seconds into an announcement before switching to the announce volume, so the start of “Front door opened” played at the quieter 45% media volume and I mostly heard “opened”. Announcements also cut each other off, since tts.speak returns as soon as the URL is handed over and the ESPHome integration doesn’t pass an enqueue flag.
My script forces the volume up front, plays a chime ahead of the speech, and waits for the player to go back to idle instead of sleeping:
The waits work because the ESPHome API reports an announcement as playing. The media player switches to playing when the chime or the speech starts and back to idle when it finishes, so each step waits for that switch instead of guessing how long the audio runs. mode: queued keeps two events seconds apart in order, instead of the second cutting off the first.
The chime can be any short sound file in Home Assistant’s www folder, which serves it at /local/. I almost never hear mine, so the start of each announcement still seems to get cut off, and the chime is what gets cut off instead of the first word. Home Assistant’s own pre-announce sound only works with voice satellites.
I only use the speaker for alerts I want to hear right away, like a water leak, and everything else stays on my phone. Automations pass the entity’s friendly name, so renaming an entity in Home Assistant changes what it says:
None of this matters if the speaker and Home Assistant share a network. Mine don’t.
The IoT network
By default my IoT devices can’t start a connection to anything. Their VLAN has no internet and is blocked from my other networks, and my OpenWrt access points run client isolation (option isolate '1'), so Wi-Fi devices on the same access point can’t reach each other either. Home Assistant reaches in to them, and the few devices that need to reach out get a firewall rule each.
Traffic to another network goes through the router, where the firewall rules apply. Traffic between two devices on the same VLAN never reaches the router, and that’s the path isolation blocks:
HA --> router --> AP --> bulb
^
firewall rules
bulb --> AP --X--> camera
^
client isolation
The firewall rule
The speaker is one of those devices. Home Assistant doesn’t stream audio to it. It sends a URL, and the speaker opens its own connection back to fetch the file, chime included, so it needs a rule to reach Home Assistant’s web port, whether it’s wired or on Wi-Fi.
Putting the speaker on Home Assistant’s VLAN would skip the rule, but it would also put the speaker next to Home Assistant, Z-Wave JS and Frigate. My rule only lets the speaker’s address through.
A speaker that shows as connected in Home Assistant and plays nothing usually means one of these:
The rule sits below the “block all other private networks” rule. OPNsense stops at the first match, so it has to go above.
Home Assistant’s internal_url (Settings -> System -> Network, labeled “Local network”) isn’t the IP and port the rule allows. With a hostname there, the speaker’s log shows a getaddrinfo() error, because the IoT network has no DNS.
A couple of popular coding-agent skills got less useful as the models improved. The caveman skill advertised a 65% token cut by writing in clipped fragments, but JetBrains measured 8.5% on real agentic work, and a common complaint is that the style wears off after a reply (#764, #1104). I’d skip Superpowers too. The models split a big prompt into a todo list and farm pieces out to subagents on their own, which covers most of what it bundled. The plan files it leaves behind are too much slop to be worth committing.
I still keep a make check target and one line in AGENTS.md telling the agent to run it before committing. It gives the agent something to check its work against instead of stopping once a change looks done. Mine runs what CI runs, so a green run locally means a green PR:
check:
npx --yes @j178/prek run --all-files$(MAKE) lint
$(MAKE) typecheck
$(MAKE)test
For a web app, that check can be a screenshot the agent takes itself. I’ve got a Makefile target that uses Playwright to open the app and save a PNG, and a line in AGENTS.md telling the agent to run it after any visual change and look at the result. Claude Code can drive a real browser through the Chrome extension, and Codex’s desktop app grew a native browser in April that screenshots before and after a patch. I stick with the Makefile target, since it works from my phone through Claude Code on the web, where there’s no desktop browser to attach to.
The agent finds plenty of visual bugs this way, but it still shows me changes that look bad without flagging them, and it misses outright mistakes. On the roguelike I’m building, four review passes looked at the town and none noticed the mushrooms drawn on top of the trees. LRR-Bench and Mind the Gap both find vision-language models near random at telling whether one object is left of, above, or rotated relative to another.
The same game’s worst recurring bug was the agent picking the wrong tile, like drawing a market stall with a signpost. The DawnLike sheets have no labels, so it was guessing from pixels until I gave it the tile names from a community atlas.
Every model in Chroma’s context rot study got worse as its input grew, so I keep to one task per chat and put whatever the next chat needs in files. That’s obvious to any programmer and probably isn’t to a lot of non-programmers, and the memory and project features in desktop AI apps look like an attempt to do it for them. AGENTS.md holds what the agent must know immediately, like where the design doc is and that make check runs before a commit. A design doc holds aesthetic rules like not overusing loud colors, and a plan doc holds what’s built and what’s next. When a chat produces a long document worth keeping, it becomes the design doc for the next one.
Installing reports success, and the command it just installed doesn’t run:
$ pip install -e .
Successfully installed repro-0.1.0
$ repro-seed
ModuleNotFoundError: No module named 'scripts'
setuptools’ automatic discovery filtered the package out. Since 61.0.0 setuptools infers your project layout with no configuration, and its flat-layout finder keeps a list of directory names it won’t treat as packages. FlatLayoutPackageFinder._EXCLUDE holds scripts along with tests, docs, tools, examples, benchmarks, tasks, build, and dist, which the docs call reserved names, filtered out because they match conventions for code you don’t distribute.
The exclusion isn’t specific to editable installs or to pip. uv pip install -e . behaves the same, and a plain pip install . builds a wheel holding nothing but dist-info metadata.
import scripts keeps working from the project directory, which puts the current directory on sys.path, so the package imports fine in a shell and under pytest and fails everywhere else.
Declaring the package explicitly overrides discovery:
Third version of the same setup: Open WebUI talking to llama.cpp on an RTX 5060 Ti with 16 GB of VRAM. Earlier posts cover why llama.cpp rather than Ollama and the Qwen3.6-35B-A3B MoE I ran until now. Qwen 3.8 landed on 14 August, and this time the model is dense: Qwen3.8-27B, every parameter active on every token.
That should not fit. A dense model has to keep all its weights in VRAM or throughput collapses, which caps this card around 14B and is why I went MoE last time. Qwen changed the attention layout to get around it, so most layers hold a small fixed-size state rather than a growing KV cache, and context costs far less here than on a normal 27B. The model card has the breakdown. UD-IQ4_XS at 13.27 GB fits at 4-bit with 64K of context, using 15769 MiB of the card’s 16311 MiB.
Qwen 3.8 defaults to xhigh and will spend tens of thousands of tokens on a trivial question. Turning it down with reasoning_effort: none, which Unsloth’s model page lists as a level, fails every request:
Error: Jinja Exception: Unexpected reasoning effort none.
Supported types are xhigh (default), medium, and low.
--reasoning off is the actual switch, LLAMA_ARG_REASONING=off in the compose below.
64K runs out on tool-heavy chats
Every request in an agent session carries the schema for every enabled tool plus everything already read, so it ends like this:
request (71461 tokens) exceeds the available context size (65536 tokens)
Open WebUI’s context compaction summarises earlier turns once a chat passes a threshold, which makes that survivable, but it’s off by default and its 80000 default sits above the 64K served here. Set it under your context size, and expect it to fire earlier than you asked, since the backend double-counts cached tokens.
Leave CONTEXT_COMPACTION_MODEL unset. Pointed at a cloud model it would ship the earlier turns of every local conversation out to be summarised automatically, which defeats the point of running locally. TASK_MODEL is unset for the same reason.
Speed
About 24.9 tokens/second decoding, and 888 prompt-processing with 21.9 decoding on a 5000-token prompt.
Serious work still goes to a hosted frontier model, but the local one has moved past redacting and cleaning text, and with the GitHub MCP tools wired in it found a real bug in one of my repos.
Minimal two-container compose:
services:llama-server:# Nightly tag: GHCR publishes no images for llama.cpp's vX.Y.Z stable tags.image:ghcr.io/ggml-org/llama.cpp:server-cuda-b10573restart:unless-stopped# Qwen's instruct sampling, plus a five-minute idle unload. None of these four# have a LLAMA_ARG_ variable, so they only work as arguments.command:>--temp 0.7 --top-p 0.8 --min-p 0--sleep-idle-seconds 300environment:# Pin downloads to the mounted volume, or a container recreate re-fetches 13 GB.-LLAMA_CACHE=/root/.cache/llama.cpp-LLAMA_ARG_HF_REPO=unsloth/Qwen3.8-27B-GGUF:UD-IQ4_XS-LLAMA_ARG_N_GPU_LAYERS=99# all layers on GPU; dense, so no --n-cpu-moe to fall back on-LLAMA_ARG_CTX_SIZE=65536# 64K, affordable here because most layers keep no KV cache-LLAMA_ARG_N_PARALLEL=1# single user, one KV cache slot-LLAMA_ARG_FLASH_ATTN=1# big KV cache VRAM savings# q4_0 rather than q8_0: resident dense weights leave less room than the MoE,# which parked ~2 GB of idle experts in system RAM. Qwen tolerates it well.-LLAMA_ARG_CACHE_TYPE_K=q4_0-LLAMA_ARG_CACHE_TYPE_V=q4_0-LLAMA_ARG_MMPROJ_AUTO=0# it's a VL model; skip the ~0.93 GB vision projector-LLAMA_ARG_BATCH=2048# not LLAMA_ARG_BATCH_SIZE, which nothing reads-LLAMA_ARG_UBATCH=512-LLAMA_ARG_TOP_K=20# the only one of Qwen's four sampling values with a variable-LLAMA_ARG_JINJA=1# correct chat template and tool calling-LLAMA_ARG_REASONING=off# thinking off; reasoning_effort cannot do this-LLAMA_ARG_PORT=11434-LLAMA_ARG_HOST=0.0.0.0volumes:-./models:/root/.cache/llama.cppports:-"11434:11434"deploy:resources:reservations:devices:-driver:nvidiacount:allcapabilities:[gpu]open-webui:image:ghcr.io/open-webui/open-webui:v0.11.0restart:unless-stoppedports:-"3000:8080"environment:-ENABLE_OLLAMA_API=false-OPENAI_API_BASE_URLS=http://llama-server:11434/v1-OPENAI_API_KEYS=no-key-ENABLE_CONTEXT_COMPACTION=true# Has to sit under CTX_SIZE. The 80000 default is above the 64K served here,# so compaction would never fire before llama.cpp refused the request.-CONTEXT_COMPACTION_TOKEN_THRESHOLD=48000-CONTEXT_COMPACTION_RETENTION_PERCENTAGE=40# share of recent messages kept verbatimvolumes:-./open-webui:/app/backend/datadepends_on:-llama-server
Qwen also recommends presence_penalty=1.5 for instruct mode to curb repetition. That has no variable either, so it goes on the command: line or in Open WebUI’s per-model Advanced Params.
I’m trying to use Claude Code’s context more efficiently on long sessions. Anthropic published a much more thorough guide on the same topic the day before I wrote this: Maximizing the value of your Claude Code sessions, covering things like /clear between tasks, @-mentioning files instead of naming them, and keeping /model and /effort settled so you don’t bust the prompt cache. Worth reading first. Two things I’ve been doing on top of it: a Stop hook that warns me when a session’s context gets too long, and a small skill for handing off work before it’s even started.
The hook fires after every response, so it can catch a session getting bloated before I notice the agent acting slower or worse:
{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"jq -r '.transcript_path' | { read -r t; tail -n 200 \"$t\" | jq -s -r '[.[] | select(.type==\"assistant\" and .message.usage != null)] | last as $l | if $l == null then empty else (($l.message.usage.input_tokens // 0) + ($l.message.usage.cache_creation_input_tokens // 0) + ($l.message.usage.cache_read_input_tokens // 0)) as $tot | if $tot > 150000 then {systemMessage: (\"Context ~\" + (($tot/1000)|floor|tostring) + \"k tokens this turn (over 150k). Consider /compact, or say create handoff and start a fresh session.\")} else empty end end'; } 2>/dev/null || true"}]}]}}
The transcript is JSONL, one message per line, so tail -n 200 finds the most recent assistant turn without scanning the whole file. The hook is read-only, so it just parses that file locally with jq and never talks to the model. What it’s reading is accounting the API already produces on every turn, hook or not. usage splits input tokens by whether they came from the prompt cache: input_tokens for what got processed fresh, cache_creation_input_tokens for what got newly cached, cache_read_input_tokens for what got served from an earlier turn’s cache. All three are still part of the model’s actual context for that turn, so summing just input_tokens badly undercounts a long session, where most of the history is being read from cache rather than reprocessed fresh. Past 150k the hook prints a systemMessage. Claude Code shows that inline without sending it back to the model, so even the warning itself costs nothing (hooks reference).
A warning is only useful if there’s a good next step, and “just start a new chat” throws away real, unplanned-for work. Most of the time that means handing off work before it’s even started, not resuming something already underway. So I wrote kickoff, and kept the whole thing to one paragraph:
Write it as a prompt for the next session, not a document for a human. That agent has the same repo access you do, so don’t summarize or quote code; point at the files and areas that matter and let it read them itself. Include the actual task, why it matters, and whatever took real investigation to establish, especially constraints or dead ends already found. End on the first concrete action, specific enough to act on without re-deriving it.
I’ve been building Brute Slicer, a turn-based tile dungeon crawler in TypeScript. Sharing a browser game is just sending a link, and it opens on a phone without an app store. Godot and Unity export to the web too, but that ships the engine itself as WebAssembly, and Godot’s export requires WebGL 2.0 in the browser. The game state only advances when the player takes a turn, and the browser handles the animations, so there’s no per-frame loop for an engine to run.
rot.js is the standard roguelike toolkit and it covers the algorithm layer. I use it mostly for pathfinding, and it also handles dungeon generation, field of view and turn scheduling, with no dependencies of its own. Its README calls the project feature-complete, and the last release was a maintenance one in November 2024.
Phaser and Excalibur are both actively developed, and either one hands you a renderer, an input system and a camera. None of them has the RPG systems layer, so turn-based combat resolution, the inventory and equipment panels, save and load, and telegraphed enemy intent are all mine to write. I render SVG rather than canvas, so the camera that follows the player is mine too. rot.js never had one.
RPG-JS comes closest, with inventory, skills, save and load, and prebuilt GUI screens. It’s built for RPG Maker-style games with maps you draw in the Tiled editor, and this game generates its floors from a seed. The rest of what turns up is abandoned. The one npm package named for the combat half, turn-based-combat-framework, last published in November 2018. Malwoden, the newer take on rot.js, last released in January 2022. The rotjs topic on GitHub is mostly finished games rather than pieces you can pull out of one.
So everyone seems to write it again, which surprised me given how many browser roguelikes are out there. My guess is that these games get finished as monoliths and nobody goes back to extract the reusable half.
The route I’ve settled on keeps the game logic in a pure sim/ module with no React and no DOM in it, and a React interface reads from it and renders SVG. There are four runtime dependencies: react, rot-js, pure-rand for the seeded RNG, and zod to validate an imported save string. rot.js has its own RNG, but it’s a global singleton, and the sim keeps all of its state explicit, so a run replays identically from its seed and the whole thing unit-tests without a browser.
I wrote recently that every LLM tool call re-sends your entire conversation, and that prompt caching brings the re-read down to about a tenth of full price. There is a lot of buzz around Kimi K3 at the moment, so I tried it in opencode, paying per token through OpenRouter and expecting caching to absorb most of the re-reads. Thirteen prompts in, the billing page said otherwise.
Four rows from the OpenRouter activity log:
Input
Output
Cost
11,235
217
$0.0364
13,949
196
$0.0443
22,210
2,699
$0.107
45,512
1,194
$0.104
Subtract the first two rows and you get the input rate: 2,714 extra tokens for $0.0079, or $2.91 per million. That is K3’s list price, so nothing was cached.
Moonshot does cache automatically, with no cache_control breakpoints to place like Anthropic’s API. The discount just doesn’t survive the trip through OpenRouter, and you can confirm that before spending anything: every K3 endpoint on OpenRouter reports supports_implicit_caching: false.
Input ended up 73% of my spend, nearly all of it conversation I had already paid for once. Shorter sessions trim that, but every turn still re-reads its whole history at full price.
That leaves two ways out: go to Moonshot’s API directly, where the discount does apply, or pick a model whose caching survives the trip. Moonshot’s plans are behind a waitlist at the moment, so I took the second one. Run the same endpoints check against any candidate and look for a cache_read price.
K3 is good, and noticeably more concise than Opus, which likes to narrate what it is about to do before doing it. I would still reach for it on a hard problem.
I only run any of this when Claude’s rate limits hit, so this is a first pass rather than a verdict. The cheapest thing that has held up so far is GPT-5.6 Terra, which caches and is on sale at the moment:
"model":"openrouter/openai/gpt-5.6-terra:online"
Cache reads run a tenth of the input price. Over a long session that is the number that decides the bill.
When it comes to LLM chats, I know long context costs more, so I try to keep mine short. What I couldn’t explain was why a single prompt still billed for many times more tokens than the context window holds.
Every time an AI agent calls a tool, it re-sends the entire conversation. The Messages API is stateless, so the model remembers nothing between requests. When it calls a tool, the request ends, your client runs the tool, appends the result to the history, and POSTs the whole thing back as a new request. You pay input tokens to remind it every time.
So a chat near the 1M-token context limit that makes 20 tool calls pays for that 1M about 20 times over, turning it into 20M billed tokens and around $20 on today’s frontier models. That is 20 times the context window, from a single prompt.
Prompt caching helps keep the cost down. Normally the model reprocesses the whole conversation on every request, but a cache lets it reuse the unchanged prefix, so re-sending that part costs about a tenth of full price. You pay a small premium to store it the first time, then read it back cheap after, the same way OpenAI (automatic caching) and Gemini (implicit caching) do it. The reuse only holds while the start of the prompt stays byte-for-byte identical, so a changing timestamp in your system prompt breaks the cache and puts you back at full price.
The Sonoff SNZB-05P seems like the best Zigbee leak sensor right now. It’s around $20, and its snap-on WLDC200 sensing cable detects water anywhere along the cord, so it isn’t just watching a single spot the water can spread around. I have them in appliance drip pans and under sinks, all feeding one Home Assistant automation that alerts my phone when a sensor trips and sends an all-clear when it dries.
automations.yaml
-id:leak_notificationsalias:Leak Notificationstriggers:# Wet: the state must hold 15s to filter momentary blips, and not_from# stops a sensor coming back from unavailable from re-alerting.-trigger:stateentity_id:-binary_sensor.kitchen_sink_leak_sensor-binary_sensor.water_heater_leak_sensornot_from:-unavailable-unknownto:'on'for:seconds:15id:wet# Dry: same sensors, opposite transition.-trigger:stateentity_id:-binary_sensor.kitchen_sink_leak_sensor-binary_sensor.water_heater_leak_sensorfrom:'on'to:'off'id:dryactions:-if:-condition:triggerid:wetthen:-action:notify.mobile_app_your_phonedata:title:Leak Detectedmessage:'Device:{{trigger.to_state.attributes.friendly_name|default(trigger.entity_id)}}hasdetectedwater.'data:# Both branches share a per-sensor tag, so the dry notification# overwrites the wet one and your phone shows a single# up-to-date entry per sensor instead of a stack.tag:leak_{{ trigger.entity_id }}# Android-specific: alarm_stream plays at alarm volume even# during Do Not Disturb (a leak at 3am is worth waking up for),# and priority high + ttl 0 deliver it immediately.priority:highttl:0channel:alarm_streamelse:# The all-clear is a normal-priority push.-action:notify.mobile_app_your_phonedata:title:Leak Drymessage:'Device:{{trigger.to_state.attributes.friendly_name|default(trigger.entity_id)}}isdry.'data:tag:leak_{{ trigger.entity_id }}# Parallel so one burst pipe tripping sensors in two rooms alerts for both.mode:parallel
To adapt
Swap in your own sensors under both entity_id lists and point the notify actions at your phone’s service. The Android keys are documented in the critical notifications docs and the tag behavior in the basic notification docs. The iOS equivalent of the alarm-stream keys is a critical push:
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/v1kind:Clustermetadata:name:pgspec:instances:3# one primary, two streaming replicasstorage:storageClass:local-nvme# benchmark this before you trust itsize:200GiwalStorage:# WAL on its own volumestorageClass:local-nvmesize:40GireplicationSlots:highAvailability:enabled:truepostgresql:parameters:shared_buffers:"4GB"# tuning is yours nowsynchronous:# opt in for RPO=0 (replication is async by default)method:any# any = quorum-basednumber: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.