Failover architecture · single-identity OpenVPN fleet
Three failures handled.
A fourth, one config line away.
A full-tunnel exit fleet runs one t4g.micro per
region behind an Elastic IP. Making it survive a crashed service,
a dead box, and silent drift needs
no load balancer, no ASG, and no health-check DNS
— just the cheapest mechanism that resolves each failure, layered
so each one's blast radius is contained by the next.
- ≈ $0/mo incremental
- no hot standby
- recovery: seconds → ~5 min
- shipped: 2026-06
Defense in depth · cheapest layer first
One layer per failure mode
Ordered by blast radius. A failure is caught by the smallest, cheapest layer that can resolve it; the outer layers exist for what the inner ones can't reach.
-
L0 · service
systemd restart
Restart=on-failureonopenvpn-server@server. Catches a segfault, or a reboot that comes up without the service.blast: one process
-
L1 · instance
EC2 auto-recovery
A CloudWatch alarm on the host status check that recovers the same box onto fresh hardware — instance ID, ENI, private IP and Elastic IP all come with it.
blast: one box
-
L2 · region
multi-remote profile
One
.ovpnlists every region; the client advances to the next exit on its own. Written and tested — emitted once a second region is deployed.blast: one region · not deployed
-
L3 · backstop
SSM healthcheck
The existing daily read-only probe. Reddens CI when self-healing didn't catch something the fleet can't see about itself.
blast: whole fleet
L0 + L1 · inside one region
A region that heals itself
The request path top to bottom, with the two recovery mechanisms hanging off the side. Because L1 recovers the same instance, the Elastic IP and DNS name come back with it — every client profile keeps working untouched, and nothing re-associates.
-
Route 53 · A record
ohio.vpn.beekley.dev— profiles point at a name, not a raw address, so a replaced Elastic IP is a one-record update instead of a config re-issue. -
Elastic IP
Retained across host recovery. The same box keeps its address through the whole process.
-
EC2 · t4g.micro
Ubuntu 24.04 · arm64 · IMDSv2. Recovered by L1 on a
StatusCheckFailed_Systemalarm, in roughly three to five minutes. -
systemd unit
Restart=on-failure. L0 resumes a crashed process in seconds, without anything else noticing. -
openvpn-server@server
UDP 1194 · AES-256-GCM · full tunnel. Watched by L3, the daily SSM healthcheck that reddens CI.
The only new resource is one CloudWatch alarm per
region. A StatusCheckFailed_System breach recovers
the instance onto healthy hardware, keeping the instance ID, ENI,
private IP and Elastic IP — so nothing re-associates and no client
profile changes. That draws a clean division of labour: L0 resumes
a crashed process, L1 a dead host. The one residual gap — the OS
up but OpenVPN wedged in a way systemd won't restart, which a
system-check alarm cannot see — is exactly what L3 exists to
redden.
L1 · the recovery nobody has to run
How a dead host heals itself
A box whose underlying hardware fails between weekly rebuilds. Note what is absent: no ASG, no load balancer, no CloudFormation in the loop, and no re-run of user data. Recovery is a stop/start of the same instance, so its disk, config and address all persist.
- Two failed system status checks trip the CloudWatch alarm.
- The alarm's recover action stops and starts the same instance on a new host.
- The EBS root volume persists — instance ID, ENI and Elastic IP are retained, so there is nothing to re-associate.
-
systemd starts
openvpn-server@serveron boot; the L0 restart drop-in is already on disk. -
Same DNS name, same address. The client reconnects on its own
through
resolv-retry infinite.
Roughly three to five minutes end to end, at the same address,
with no human in the loop. Because recovery preserves the
instance, this is far simpler than an ASG replacement:
no Elastic IP re-association, no fresh AMI, no re-run of
bootstrap.py. The root volume — carrying the restart drop-in and the server
config — comes back intact. The gate is the system status check,
and there is deliberately no tunnel-probing load balancer. The
CreationPolicy fail-closed signal still governs
deploys; it just isn't part of this autonomous self-heal.
L2 · written, not running
The region layer, and why it is dormant
One identity, one profile naming every exit. No DNS trickery and
no server-side moving parts — the failover lives in the OpenVPN
client's own retry loop. The client tries the first region; if it
is unreachable, connect-timeout 10 gives up and dials
the next remote in the list. A full cross-region
cutover is about ten seconds, with no infrastructure aware it
happened.
It works because every server certificate is signed by the
one CA and the client authenticates by
certificate (remote-cert-tls server), not hostname —
so it already trusts every exit. The profile ships as an extra
-failover.ovpn beside the per-region files, leaving
deliberate region choice intact.
That is the design, and the code for it is written and tested. It
is not what runs today. The fleet was consolidated to a single
region for cost, and
pki.py guards the profile at
len(endpoints) > 1 — with one exit there is
nowhere to fail over to, so no failover profile is generated at
all. The honest status is that L2 is one entry in
config.json away, not that it is protecting anything
right now.
The whole story on one line each
Failure → response
| Failure mode | Layer | What recovers it | Recovery | Human |
|---|---|---|---|---|
| Service crashes segfault, OOM, reboot without the unit | L0 | systemd restarts openvpn-server@server |
seconds | none |
| Host dies failed system status check, underlying hardware | L1 | CloudWatch alarm recovers the same box on new hardware (Elastic IP follows) | ~3–5 min | none |
| Region unreachable AWS region outage, or the path to it is down | L2 | Client advances to the next remote — not deployed; single region today | ~10 sec | reconnect |
| Silent drift OpenVPN wedged but host healthy; config rot | L3 | Daily SSM healthcheck reddens CI | next run | triage |
Why it stays cheap
The money argument is the design argument
The fleet's cost is set by the boxes that have to run anyway. Resilience rides on mechanisms that are free, already paid for, or client-side — which is also why consolidating to one region was a config change rather than a redesign.
Incremental cost ledger
- L0 systemd
Restart= - $0.00
- L1 CloudWatch auto-recovery alarm
- $0.00
- L2 multi-remote profile
- $0.00
- L3 healthcheck (already deployed)
- $0.00
- Added to the monthly bill
- ≈ $0.00
One alarm per region sits inside the standing ten-alarm CloudWatch free tier, and $0.10/mo each beyond it — so ≤ $0.40/mo even at the full four-region design.
Deliberately excluded
- An ASG of one
- Would also self-heal an OS-level wedge and a terminated box — but it rewrites the whole tested surface and moves the Elastic IP and the readiness signal into boot steps. L0 and the weekly rebuild already cover service and AMI freshness, so auto-recovery closes the one real gap for a fraction of the change.
- A UDP Network Load Balancer
- Roughly $16/mo per region to front a single instance. It buys nothing auto-recovery doesn't, and adds a component that can itself fail.
- Route 53 health-check failover
- Route 53 can't cheaply probe UDP 1194; you would bolt on a TCP health port or a CloudWatch alarm check. L2's client-side retry gets region failover for free — when there is more than one region to get it between.
Stack
AWS CDK (TypeScript) · Python (PKI + orchestration) · OpenVPN 2.6 · Ubuntu 24.04 LTS on t4g.micro · EC2 auto-recovery · Route 53 · Secrets Manager · SSM · GitHub Actions with OIDC · Jest + pytest
One CA whose private key never leaves the laptop; nothing secret
is generated on the servers. tools/pki.py signs a
server certificate per region and pushes each region's materials
into that region's Secrets Manager secret, which the instance
reads at boot. Failover by layering, not by spending.