Oracle VM Reclaimed
Oracle Cloud Infrastructure can reclaim Always Free instances after 7 consecutive days where p95 CPU < 20 %, network < 20 %, and memory < 20 % (ADR-019). The application stack is sized to stay above the threshold (steady-state ~29 % memory), but a long quiet period plus a worker crash can trip it.
Recovery is a single ansible-playbook run (ADR-061). The whole VM (Docker, Traefik, cloudflared, shared Valhalla + PG-reference, the prod env file and both keypairs from Vault, backup timer) is described in ansible/; bootstrapping a fresh VM and recovering a reclaimed one are the same command.
Symptoms
- UptimeRobot external monitor red on
/api/healthz - SSH to the VM times out / refuses connections
- OCI console: the instance is in
STOPPED,TERMINATED, or has been removed entirely - Email from Oracle stating "Always Free resources reclaimed"
Diagnosis
From a workstation:
ssh deploy@<vm-host> # through the Cloudflare Access SSH app; the VM has no public IP
curl -sS -o /dev/null -w '%{http_code}\n' https://www.<domain>/api/healthz
A Cloudflare 502/530 on the public URL means the tunnel has no live cloudflared
connector, i.e. the VM (or its Docker daemon) is down.
In the OCI console:
- Compute → Instances → check the instance state
- Compute → Boot volumes → confirm the boot volume is still listed (volumes survive instance termination for 7 d by default)
- Audit → search for the
TerminateInstanceevent with reason
Procedure
-
If the instance is
STOPPED, just start it from the OCI console (Compute → Instances → Start). Docker services (restart: unless-stopped) come back on boot; the stack is up in a few minutes. No action needed beyond confirming health. -
If the instance was terminated but the boot volume is preserved (the common reclaim path):
- OCI console → Compute → Create Instance
- Shape:
VM.Standard.A1.Flex, 4 OCPU / 24 GB RAM - Image source: "Boot volume" → select the preserved volume
- Subnet: same VCN as before. No reserved public IP: ingress is the outbound Cloudflare Tunnel, nothing to reattach.
- Launch. The VM boots with everything already configured. Confirm
cloudflaredreconnected and/api/healthzis green. - If the host SSH key changed, update the
SSH_KNOWN_HOSTSGitHub secret (when set), ordeploy-prodwill refuse the host.
-
If the boot volume is also gone (rare: full reclaim after long inactivity), re-run the Ansible playbook on a fresh VM:
- Provision a fresh
VM.Standard.A1.Flex(Ubuntu ARM64, 4 OCPU / 24 GB) with no public IP. OnOut of host capacity, retry or change availability domain or region. First SSH via the OCI serial console or a temporary public IP. - From a workstation, in
ansible/:ansible-galaxy collection install -r requirements.yml, thenansible-playbook -i inventory.ini playbook.yml --ask-vault-pass. This reinstalls Docker, Traefik, cloudflared, the shared Valhalla and PG-reference, the prod env file, both keypairs and the backup timer. Seeansible/README.md. - Seed the routing graph:
make routing-publish deploy@<vm-host> <slug> [slug...]from a workstation that holds a built graph (see valhalla-routing-graph.md), or ship the tar, setvalhalla_tiles_taringroup_vars/all.ymland re-run the playbook. - Deploy the app: re-run GHA
deploy-prodfor the current tag, or run/opt/bike-trip-planner/deploy-prod.sh <tag>on the VM. The fresh clone is onmain, andbtp-compose(every prod compose call) refuses to run until this step has put it on a release tag. - Restore PG-app from the latest backup: follow the restore procedure in ADR-062 (the
ageprivate key is in Bitwarden, see secrets-inventory.md). - PG-reference is not backed up: it is reproducible by re-opening each zone (see zone-opening.md).
- Update the
SSH_HOST/SSH_KNOWN_HOSTSGitHub secrets if the host changed. - Cloudflare DNS already points
www+*at the tunnel CNAME (<UUID>.cfargotunnel.com); no DNS record to update.
- Provision a fresh
-
Notify users — use a GitHub repository issue or a pinned PWA banner once the app is back.
Verification and follow-up
/api/healthzgreen from UptimeRobot and a manual curl.- A new incident issue with severity P1 documenting the reclaim cause (likely "VM idle for 7 d").
- If reclaim recurs, enable the optional anti-reclaim heartbeat timer (
enable_reclaim_heartbeat: trueinansible/group_vars/all.yml; it hits/api/healthzthrough the tunnel). The steady-state footprint already clears the 20 % threshold, so this is belt-and-braces. - File a post-mortem using
incident-template.md— even if recovery was quick, the data loss risk warrants the analysis.
References
- ADR-019 — Deployment infrastructure (Oracle Always Free reclaim policy)
incident-template.md— post-mortem template