Docker & ContainersXXII Β· Disaster RecoveryRPO and RTO
RPO and RTO β defining recovery objectives
What you'll learn
- Derive RPO and RTO from cost per hour rather than from what sounds reassuring
- Decompose an RTO into its components and find which one dominates
- Match a single-host Docker architecture to the objectives it can actually meet
- Recognise the four ways a stated RPO is quietly wrong
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
Two definitions, and they are not symmetric:
- RPO β Recovery Point Objective. The maximum acceptable amount of data loss, expressed in time. RPO 1 hour means that after a disaster you may be missing up to an hour of work, and that is acceptable.
- RTO β Recovery Time Objective. The maximum acceptable outage, expressed in time. RTO 4 hours means the service is expected back within four hours.
The asymmetry that matters: RPO is bought with backup frequency, RTO is bought with infrastructure, and the two are unrelated purchases. Backing up every five minutes improves RPO to five minutes and improves RTO by exactly nothing β you still have to provision a host, pull images and replay a dump. Teams who believe more frequent backups make recovery faster are budgeting for the wrong thing, and they find out during a drill or an outage.
Deriving the numbers instead of picking them
βRPO 1 hour, RTO 4 hoursβ appears in a great many documents and is usually a number that sounded responsible. The derivation is not difficult and it changes the answer.
Decomposing RTO for a single Docker host
RTO is not one number, it is a sum, and the sum is dominated by one or two terms that are rarely the ones people optimise.
| Phase | Typical, unprepared | Typical, prepared | What changes it |
|---|---|---|---|
| Detect | 5β30 min | 1β2 min | Alerting on the service, not on the host |
| Decide to fail over | 10β60 min | 2 min | A written trigger condition and a named decider |
| Provision a host | 30β120 min | 0 (already running) | Warm standby, or an image and IaC |
| Install and configure the engine | 15β40 min | 0 | Pre-built host image with daemon.json |
| Pull images | 5β30 min | 0 | Pre-pulled, or a local registry mirror |
| Restore volume data | 20 min β 6 h | 5β20 min | Volume size, transfer rate, restore parallelism |
| Start the stack and reach healthy | 2β15 min | 2β15 min | Health check start periods, migrations |
| Repoint DNS or the load balancer | 1 min β 1 h | 1β5 min | TTL, and whether the change is automated |
| Verify | 10β30 min | 5 min | A written content assertion, not a look around |
Two lines deserve attention because they are consistently underestimated.
Decide to fail over is often the largest term in a real incident and appears in no plan. The host stopped responding at 02:14; at 02:47 the on-call engineer is still trying to determine whether it will come back, because failing over is irreversible-ish and nobody wants to be the person who did it unnecessarily. Thirty-three minutes of RTO consumed by an absent decision rule. The fix is a written trigger: if the host has not responded to SSH for 10 minutes and the out-of-band console shows no progress, we fail over. Put a name against it.
Repoint DNS is bounded below by your recordβs TTL, and resolvers routinely ignore short TTLs. A 3,600-second TTL is an hour of RTO you cannot recover by working faster. Lower it to 60 seconds now, permanently, as a standing decision; the extra query volume is negligible and it converts an hour into a minute.
STACK=/srv/app
# How long does the stack take from cold to all-healthy?
docker compose -f "$STACK/compose.yaml" down
time docker compose -f "$STACK/compose.yaml" up -d --wait
# How much data has to move?
docker system df -v | sed -n '/Local Volumes/,$p'
# What is the DNS TTL you are actually bound by?
dig +noall +answer app.example.com | awk '{print $2, $1}'docker compose up -d --wait blocks until every service with a health check
reports healthy, and returns non-zero if any does not. That makes it a
measurement rather than a guess: the number it prints is the βstart the stack
and reach healthyβ row of your table, on this host, today.
$ time docker compose -f /srv/app/compose.yaml up -d --wait[+] Running 5/5
β Network app_default Created
β Container app-db Healthy
β Container app-redis Healthy
β Container app-web Healthy
β Container app-caddy Healthy
real 3m48.114s
user 0m0.412s
sys 0m0.180sIllustrative output
Three minutes forty-eight, with images already present and volumes already populated. Any RTO commitment shorter than that is arithmetically impossible before a single byte has been restored, and this is a four-minute experiment.
What a single Docker host can actually promise
| Architecture | Achievable RPO | Achievable RTO | Honest description |
|---|---|---|---|
| One host, nightly dump offsite | 24 h | 4β8 h | Adequate for internal tools |
| One host, hourly dump offsite | 1 h | 4β8 h | RPO improved, RTO untouched |
| One host, WAL archiving offsite | 5 min | 4β8 h | Cheapest large RPO improvement available |
| Two hosts, cold standby provisioned | 5 min β 1 h | 30β60 min | Provisioning and engine install removed |
| Two hosts, warm standby, volumes synced hourly | 5 min | 15β30 min | Data restore mostly removed |
| Two hosts, streaming DB replica, manual promotion | Seconds | 5β15 min | The realistic floor for standalone Docker |
| Anything claiming automatic failover | β | β | Not standalone Docker. See the HA part. |
The last row is the boundary. Standalone Docker has no mechanism that notices a
host has died and starts the workload elsewhere. restart: always restarts a
container on a host that is still running its daemon; it does nothing at all
when the host is gone. An RTO in single-digit minutes therefore requires a human
who is awake, or an orchestrator. Committing to one without either is committing
to a coin flip on whether the outage starts during working hours.
export RESTIC_REPOSITORY='rest:http://backup-01.example.com:8000/app-01'
export RESTIC_PASSWORD_FILE=/etc/restic/password
# Newest snapshot in the OFFSITE repository, per backup path
restic snapshots --host app-01 --latest 1
# Same thing as an age in hours, for a monitoring check that can fail
newest=$(restic snapshots --host app-01 --latest 1 --json | jq -r '.[0].time')
age_h=$(( ( $(date +%s) - $(date -d "$newest" +%s) ) / 3600 ))
echo "newest offsite snapshot is ${age_h}h old"
[ "$age_h" -le 24 ] || { echo 'FAIL: offsite copy exceeds stated RPO' >&2; exit 1; }Run that and compare the output to the RPO in your documentation. The gap between them is your actual exposure, and it is usually a surprise.
Knowledge check
Knowledge check Β· 5 questions
Q1. A team moves from nightly backups to hourly backups. What happens to RPO and RTO?
Q2. The nightly dump starts at 02:00 and finishes at 03:30. The offsite sync runs at 06:00. The host is lost at 04:00. What is the actual data loss?
Q3. Which RTO components can be reduced to near zero without buying additional hardware? Select all that apply.
Q4. Which command turns the "start the stack and reach healthy" RTO component from an estimate into a measurement?
Q5. A system whose database has an RPO of one hour and whose uploads volume is copied weekly has an RPO of one hour.
Passing score: 75%. Answers are checked in this browser.