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
@@ -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`). */