Sign inSign up

centile/under-maintenance

By centile

•Updated about 1 month ago

Simple web server serving only an "under maintenance" page

Image
0

78

centile/under-maintenance repository overview

⁠under-maintenance

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

⁠What the container does

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:

URLResponsePurpose
/healthz200 okprobe for a load balancer / orchestrator (and the image's HEALTHCHECK)
/style.css200 text/cssotherwise the maintenance page would arrive unstyled

⁠Language selection

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-LanguageLanguage served
(absent), en-GB, de,es;q=0.7, *en
fr, FR-fr, fr-FR,fr;q=0.9,en;q=0.8fr
de-DE,de;q=0.9,fr;q=0.8fr (fr mentioned, en absent)
en-US,en;q=0.9,fr;q=0.8en (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.

⁠Values configurable at startup

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
VariableDefaultEffect
CONTACT_EMAIL[email protected]address behind the footer link; empty ⇒ the link disappears instead of rendering an empty mailto:
RETRY_AFTER3600value 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 config — 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.
  • HTML pages — 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.
⁠Adding a variable

Site name, status page URL, expected return date… three places:

  1. the ${MY_VAR} placeholder in index.fr.html and index.en.html;
  2. ${MY_VAR} added to SUBST_VARS in envsubst-html.sh;
  3. ENV MY_VAR="default" in the Dockerfile.

For a variable used in the nginx config, also add it to the NGINX_ENVSUBST_FILTER regex.

⁠Known limitations

  • Read-only file system. Pages are rendered into /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.
  • No HTML escaping. 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.
  • Runs as root. The image runs as root and listens on port 80. For a context enforcing runAsNonRoot, start from nginxinc/nginx-unprivileged:alpine and change listen 80 to listen 8080 in nginx.conf.template.

⁠Files

FilePurpose
Dockerfilenginx:alpine image + defaults (ENV) + HEALTHCHECK
nginx.conf.templatelanguage negotiation, blanket 503, /healthz, headers
envsubst-html.shrenders the pages at container startup
index.fr.html, index.en.htmlthe two versions of the page
style.cssstyling 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.

Tag summary

Content type

Image

Digest

sha256:850561be9…

Size

25 MB

Last updated

about 1 month ago

docker pull centile/under-maintenance