Simple web server serving only an "under maintenance" page
78
A static "site under maintenance" landing page, served by nginx in a container. Bilingual French / English, with no external dependency (no CDN, no images, no JavaScript).
docker build -t under-maintenance .
docker run -d --rm -p 8080:80 under-maintenance
Every URL returns the maintenance page with a genuine HTTP 503, along with Retry-After —
which matters so search engines treat the outage as temporary and do not deindex the site. The
response also carries Cache-Control: no-store, so no cache keeps serving the maintenance page
once the site is back.
Two exceptions to the blanket 503:
| URL | Response | Purpose |
|---|---|---|
/healthz | 200 ok | probe for a load balancer / orchestrator (and the image's HEALTHCHECK) |
/style.css | 200 text/css | otherwise the maintenance page would arrive unstyled |
The language is negotiated server-side from the browser's Accept-Language header, through
an nginx map. English by default; French as soon as fr appears before en in the list:
map $http_accept_language $accept_lang {
default en;
~*^(?:(?!\ben\b).)*\bfr\b fr;
}
Browsers already sort Accept-Language by decreasing preference, so whichever of the two
languages is mentioned first is the one the user wants. A simpler "contains fr" rule would be
wrong: it would serve French to a browser sending en-US,en;q=0.9,fr;q=0.8.
Accept-Language | Language served |
|---|---|
(absent), en-GB, de,es;q=0.7, * | en |
fr, FR-fr, fr-FR,fr;q=0.9,en;q=0.8 | fr |
de-DE,de;q=0.9,fr;q=0.8 | fr (fr mentioned, en absent) |
en-US,en;q=0.9,fr;q=0.8 | en (en mentioned first) |
The response carries Content-Language and Vary: Accept-Language — the latter so a cache or a
CDN does not serve the same language to everyone.
/fr and /en force the language regardless of the browser. The switch link in the footer
points at them, which covers the awkward case of a browser that would not order its list by
decreasing q-value (en;q=0.3,fr;q=0.9 would yield English): real q-value sorting would require
Lua or a third-party module.
Nothing is hard-coded in the image: values are injected when the container starts.
docker run -d -p 8080:80 \
-e [email protected] \
-e RETRY_AFTER=900 \
under-maintenance
| Variable | Default | Effect |
|---|---|---|
CONTACT_EMAIL | [email protected] | address behind the footer link; empty ⇒ the link disappears instead of rendering an empty mailto: |
RETRY_AFTER | 3600 | value in seconds of the 503's Retry-After header |
The mechanism relies on the official nginx image entrypoint, which runs every script in
/docker-entrypoint.d/ before starting nginx and already ships envsubst:
nginx.conf.template is copied into /etc/nginx/templates/ and rendered by
the image's 20-envsubst-on-templates.sh. By default that script would substitute every
environment variable, which would wipe out $uri, $page_lang and $http_accept_language;
hence the NGINX_ENVSUBST_FILTER="^(RETRY_AFTER)$" guard set in the Dockerfile.envsubst-html.sh, copied to /docker-entrypoint.d/40-envsubst-html.sh,
renders /usr/share/nginx/html-templates/*.html into /usr/share/nginx/html/, substituting
only the SUBST_VARS allowlist.Site name, status page URL, expected return date… three places:
${MY_VAR} placeholder in index.fr.html and index.en.html;${MY_VAR} added to SUBST_VARS in envsubst-html.sh;ENV MY_VAR="default" in the Dockerfile.For a variable used in the nginx config, also add it to the NGINX_ENVSUBST_FILTER regex.
/usr/share/nginx/html at startup: with
--read-only (or readOnlyRootFilesystem under Kubernetes), mount an emptyDir / tmpfs on
that path. The script fails loudly rather than serving raw ${...} placeholders.envsubst does not protect injected values: a value containing < or
" would break the page. Harmless for an email address you supply yourself, worth keeping in
mind if the value ever comes from elsewhere.runAsNonRoot, start from nginxinc/nginx-unprivileged:alpine and change listen 80 to
listen 8080 in nginx.conf.template.| File | Purpose |
|---|---|
Dockerfile | nginx:alpine image + defaults (ENV) + HEALTHCHECK |
nginx.conf.template | language negotiation, blanket 503, /healthz, headers |
envsubst-html.sh | renders the pages at container startup |
index.fr.html, index.en.html | the two versions of the page |
style.css | styling shared by both languages |
The page itself: radial gradient background, frosted-glass card, inline SVG gear icon turning
slowly, responsive, automatic light/dark theme via prefers-color-scheme, animations disabled
under prefers-reduced-motion.
Content type
Image
Digest
sha256:850561be9…
Size
25 MB
Last updated
about 1 month ago
docker pull centile/under-maintenance