Show a static maintenance page while the service restarts

A deployment's ErrorDocument-covered downtime window looked dead: Apache's
default 502/503/504 error page, or a hung request, while instance-update
briefly restarts the systemd unit. init --systemd now also writes a static
werkator-maintenance.html next to the generated .htaccess, and the .htaccess
maps a refused connection straight to it — served by Apache alone, no
change needed to Werkator itself since it is exactly the process that is
down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 17:48:06 +02:00
co-authored by Claude Sonnet 5
parent 06967c551e
commit ca9c23d55b
6 changed files with 74 additions and 2 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ Then, you have to configure *Werkator* by amending this config file according to
`init --apply FILE` installs a YAML fragment in the configuration schema as the applied instance layer — see [configuration.md](configuration.md#the-applied-instance-fragment-init---apply).
Deployment tooling hands its parameters over this way instead of patching config files; the fragment is validated strictly and replaced wholesale on re-apply.
It runs before `--systemd`, so an applied `server.port` reaches the generated unit and the Apache `.htaccess` (written beside the units when a `publicBaseUrl` is configured).
It runs before `--systemd`, so an applied `server.port` reaches the generated unit and the Apache `.htaccess` (written beside the units when a `publicBaseUrl` is configured, together with the static `werkator-maintenance.html` its `ErrorDocument`s fall back to while the service restarts).
## Output
+1
View File
@@ -345,5 +345,6 @@ tools/remote --env-file .env.mih34 werkator instance-update
```
The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset.
For the few seconds `instance-update` restarts the service, nothing listens on its port; the generated `.htaccess` maps that refused connection (`ErrorDocument 502/503/504`) to a static `werkator-maintenance.html` placed next to it by `instance-start`, so the page reads "Werkator is restarting" instead of Apache's default error page or a hung request.
The `/system` page's disk metric is quota-aware (PR#16): on a Managed Webspace the binding limit is usually the package's group quota, not the free space of the shared host volume, so `diskTotalGib` there is the quota's soft limit — the info line names it (`group quota <package>, hard limit … GiB`) instead of showing the host's full disk size.
@@ -359,6 +359,13 @@ class InitCommand(
normalizedWorkingDir.toFile(),
)} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)",
)
val maintenancePageFile = werkatorDir.resolve(SystemdServiceFiles.MAINTENANCE_PAGE_NAME)
maintenancePageFile.toFile().writeText(SystemdServiceFiles.maintenancePageContent())
println(
"created ${maintenancePageFile.toFile().relativeTo(
normalizedWorkingDir.toFile(),
)} (shown by Apache while the service restarts; copy it next to the .htaccess)",
)
}
println("install and start the service and the nightly Docker cleanup with:")
@@ -13,6 +13,9 @@ object SystemdServiceFiles {
/** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */
const val HTACCESS_NAME = "werkator.htaccess"
/** Static page [maintenancePageContent] serves at, referenced by [htaccessContent]'s `ErrorDocument`s. */
const val MAINTENANCE_PAGE_NAME = "werkator-maintenance.html"
const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service"
const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer"
@@ -96,15 +99,53 @@ object SystemdServiceFiles {
* Apache terminates TLS for the domain and forwards everything to the
* localhost port of the "eigener Serverdienst". Generated host integration
* like the units — the wrapper copies it into the domain's docroot.
* `ErrorDocument` maps a refused connection (Apache's 502/503/504 while the
* service restarts) to the static [maintenancePageContent] instead of Apache's
* default error page; the `RewriteCond` keeps that one file from being proxied
* itself, since `ErrorDocument` serves it as a sub-request through the same rules.
*/
fun htaccessContent(port: Int): String =
"""
DirectoryIndex disabled
ErrorDocument 502 /$MAINTENANCE_PAGE_NAME
ErrorDocument 503 /$MAINTENANCE_PAGE_NAME
ErrorDocument 504 /$MAINTENANCE_PAGE_NAME
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_URI} !^/${MAINTENANCE_PAGE_NAME}${'$'}
RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy]
""".trimIndent() + "\n"
/**
* Static fallback for [htaccessContent]'s `ErrorDocument`s: shown by Apache directly,
* without involving Werkator, during the brief window where the service restarts and
* nothing listens on its port yet (`instance-update`). Self-contained — no external
* assets, since nothing would be there to serve them while Werkator itself is down.
*/
fun maintenancePageContent(): String =
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Werkator — Maintenance</title>
<style>
body { font-family: sans-serif; background: #1e1e1e; color: #eee; display: flex;
align-items: center; justify-content: center; height: 100vh; margin: 0; }
div { text-align: center; }
h1 { font-size: 1.4rem; margin-bottom: .5rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div>
<h1>Werkator is restarting</h1>
<p>A deployment is in progress. Please retry in a few minutes.</p>
</div>
</body>
</html>
""".trimIndent() + "\n"
private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-")
/** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */
@@ -97,5 +97,26 @@ class SystemdServiceFilesTest : FunSpec() {
content shouldContain "DirectoryIndex disabled"
content shouldContain "RewriteRule .* http://127.0.0.1:18088%{REQUEST_URI} [proxy]"
}
test("the htaccess maps a refused connection to the static maintenance page") {
val content = SystemdServiceFiles.htaccessContent(18088)
content shouldContain "ErrorDocument 502 /werkator-maintenance.html"
content shouldContain "ErrorDocument 503 /werkator-maintenance.html"
content shouldContain "ErrorDocument 504 /werkator-maintenance.html"
// the maintenance page itself must not be proxied, or ErrorDocument's sub-request loops
content shouldContain "RewriteCond %{REQUEST_URI} !^/werkator-maintenance.html$"
}
test("the maintenance page is a self-contained, static page naming a retry") {
val content = SystemdServiceFiles.maintenancePageContent()
content shouldContain "<html"
content shouldContain "restarting"
content shouldContain "retry"
content shouldNotContain "http://"
content shouldNotContain "https://"
content shouldNotContain "src="
}
}
}
+3 -1
View File
@@ -357,9 +357,11 @@ instance_start() {
local htaccess_src="$TARGET_DIR/werkator/.git/werkator/werkator.htaccess"
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
local maintenance_src="$TARGET_DIR/werkator/.git/werkator/werkator-maintenance.html"
local maintenance="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/werkator-maintenance.html"
if ssh "$HOST" "test -f '$htaccess_src'"; then
echo "==> Placing the generated Apache reverse proxy at $htaccess"
ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess'"
ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess' && cp '$maintenance_src' '$maintenance'"
else
echo "==> No werkator.htaccess generated (no server.publicBaseUrl configured) — skipping the Apache proxy"
fi