Three Ways to Show a Maintenance Page on AWS — ALB Rule, WAF, or DNS Switch, and Why DNS Lost
Which option still works with the app stopped, costs nothing day to day, and can be undone in a second? Body size limits checked against the docs, and the keep-alive trap
I wanted a "we're under maintenance" page that lives entirely inside AWS. Two conditions: it costs nothing on normal days, and when the work goes sideways I can turn it off right away. After reading this you'll know which of three options to pick (an ALB listener rule, WAF, or a Route 53 DNS switch), how big a body each one allows, and why switching DNS is slower than it looks.
An ALB (Application Load Balancer) takes requests and spreads them over servers behind it, such as ECS tasks. WAF is a firewall in front of it that inspects requests and blocks or allows them.
Don't build it as app middleware
The first idea is usually a flag checked in something like Rails middleware. But maintenance is exactly when the app goes down. If you are mid-migration or have scaled tasks to zero, that middleware is dead too. The answer has to come from in front of the app: the ALB or WAF.
One more thing that tripped me up. "A 503 rule" sounds like "when the app returns 503, show a nice page." It's the other way round. It's a switch that deliberately returns 503 from our side. The app can be perfectly healthy and users still get 503.
The three side by side
| ALB listener rule | WAF default action | API Gateway + Lambda + Route 53 | |
|---|---|---|---|
| Extra resources | 0 | 0 (if you already run WAF) | 13 in our design |
| Turn on / roll back | Instant | Instant | Depends on DNS, see below |
| Body limit | 1,024 characters | 4 KB | Not a concern |
| Let only the office through | Add a forward rule at higher priority | IP set + Allow rule | Build it yourself |
| Works with the app stopped | Yes | Yes | Yes |
I checked the body limits in the docs. For the ALB fixed-response, the API reference lists MessageBody with a maximum length of 1,024. The content type is one of five, text/html included. 1,024 characters fits one short HTML page with a bit of CSS. A base64 logo won't fit.
WAF was a little funny. The API reference (CustomResponseBody) says Content can be up to 10,240 long. The quotas table says the body of a single custom response is at most 4 KB. My first notes said 10,240, but the docs disagree with each other. I didn't actually try a 10 KB body. Stay under 4 KB.
Why the DNS switch loses: rolling back is slow
Here you keep an API Gateway + Lambda maintenance endpoint around and point the Route 53 record at it. It looks like the most obvious option, and at first I thought it was the right one.
The problem is that DNS doesn't change when you tell it to. For an alias record pointing at an ALB you can't set the TTL; Route 53 uses the ELB TTL of 60 seconds (docs). Sixty seconds sounds fine, but on top of that:
some corporate and ISP resolvers enforce a minimum TTL that is longer
browsers keep their own DNS cache
an HTTP connection that's already open never asks DNS again
The last one matters most. Browsers reuse one keep-alive connection. Until it closes, requests keep going to the old destination. On the ALB side, the HTTP client keepalive duration defaults to 3,600 seconds. It counts from when the connection was opened and isn't reset by traffic (docs). So a user who keeps clicking can stay on the old ALB for close to an hour. If the connection sits idle for 60 seconds, the idle timeout closes it sooner.
To get a feel for it I faked it locally. I flipped a fake DNS table from app to maintenance and printed where a new connection and a kept-alive connection went.
before switch new-conn=app keep-alive=app
after switch 1 new-conn=maintenance keep-alive=app
after switch 2 new-conn=maintenance keep-alive=app
after switch 3 new-conn=maintenance keep-alive=app
after reconnect new-conn=maintenance keep-alive=maintenance
Obvious in hindsight, but seeing it printed made it click. The open connection keeps hitting the app until it reconnects.
I can live with maintenance starting a few minutes late. Waiting minutes, maybe tens of minutes, when the work failed and I need to roll back fast is a different story. An ALB rule or WAF change is one API call and applies from the next request. Connections are already attached to the ALB, so keep-alive doesn't matter.
So I filed the DNS switch under "only when the ALB itself is gone", like a regional outage.
Turning it on and off with an ALB rule
Put a fixed-response 503 rule at priority 1 and it matches before the default forward.
aws elbv2 create-rule --listener-arn <listener-arn> --priority 1 --conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/*"]}}]' --actions '[{"Type":"fixed-response","FixedResponseConfig":{"StatusCode":"503","ContentType":"text/html","MessageBody":"<h1>Under maintenance</h1><p>Back at 22:00</p>"}}]'
delete-rule turns it off. To let the office check things first, put a rule that forwards to the real target group with a source-ip condition ahead of it (there's no priority 0, so move the 503 rule to 2 and use 1). source-ip has no "not this IP", which is why it takes two rules.
One catch. source-ip sees the address connected to the ALB. With CloudFront in front, you see CloudFront's IP. The docs say X-Forwarded-For doesn't match this condition; you need an http-header condition for that.
Turning it on and off with WAF
If WAF is already attached to the ALB, change the web ACL default action from Allow to Block and give it a custom response with 503 and a body. Put office IPs in an IP set with one Allow rule and you're done. Unlike the ALB rule you can add response headers (anything but content-type), so Retry-After works too.
If you don't run WAF yet, the web ACL itself has a monthly charge. That's a weak reason to add it just for a maintenance page. Use the ALB rule.
While the 503 is up, everything inside still works
This one is easy to get wrong too. The 503 is a gate for HTTP coming in from the internet. The paths behind it stay open.
ECS Exec goes through SSM Session Manager. Neither the ALB nor WAF is in the way
Deploys are ECR push →
ecs update-service. All AWS API callsALB health checks go from the ALB straight to the targets. They skip listener rules and WAF, so ECS won't kill tasks or roll back for failing health checks
Service-to-service calls through an internal ALB are unaffected if WAF is only on the public ALB
External monitoring (Synthetics and other checks from outside) will see 503 and go red. Mute it before you flip the switch or the alerts pile up.
How to pick
A short HTML page is enough → ALB listener rule. Zero resources, one command
You want a designed page, office IP bypass or
Retry-After, and WAF is already there → WAF default actionThe ALB itself is unusable → only then switch DNS, and design as if TTLs won't be honored
You use a maintenance page a few times a year, so it's often broken when you finally need it. Flip it on and off in staging once in a while.
If you need to plan zero-downtime work or a maintenance procedure on ECS and ALB and don't have the hands for it, ask us at https://www.negabaro.com/contact.
How It Works
Serve the page in front of the app (ALB or WAF); app middleware dies with the app
For a short HTML page, an ALB listener rule with fixed-response 503 (body up to 1,024 characters)
If WAF is already there, set the default action to Block with 503: 4 KB body, IP set, Retry-After
A DNS switch is slow to undo because keep-alive connections never re-resolve; use it only when the ALB is gone
Mute external monitoring before switching on; health checks, ECS Exec and deploys keep working
Use Cases
References
- AWS — FixedResponseActionConfig (MessageBody 1,024)
- AWS — Listener rule condition types (source-ip)
- AWS — ALB attributes (HTTP client keepalive duration)
- AWS WAF — Quotas (custom response body 4 KB)
- AWS WAF — Custom responses for Block actions
- Route 53 — Alias records and TTL
- ELB — Request routing (DNS TTL 60 seconds)