feat(gateway): add nginx gateway for maintenance mode fallback

Proxy frontend traffic through Nginx to handle service restarts gracefully
Serve a static 503 maintenance page when frontend or backend is unavailable
Update deployment design docs and docker-compose configuration
This commit is contained in:
2026-05-04 09:12:39 +08:00
parent 03f5735bd2
commit bcff83a512
4 changed files with 288 additions and 2 deletions

View File

@@ -0,0 +1,224 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<meta http-equiv="refresh" content="30" />
<title>Pokopia Wiki is upgrading</title>
<style>
:root {
color-scheme: light;
--pokemon-yellow: #ffcb05;
--pokemon-yellow-soft: #ffe46b;
--pokemon-blue: #2a75bb;
--pokemon-blue-deep: #003a70;
--pokemon-red: #ee1515;
--pokemon-red-deep: #cc0000;
--bg: #f2f5fa;
--bg-alt: #eaf1fb;
--surface: #ffffff;
--surface-soft: #f8fafd;
--ink: #151923;
--ink-soft: #354052;
--muted: #687487;
--line: #d8deea;
--line-strong: #1f2a3b;
--shadow-raised: 0 14px 32px rgba(23, 35, 54, .13);
--font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: "Arial Rounded MT Bold", "Nunito", "Avenir Next Rounded", var(--font-sans);
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
}
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
font-family: var(--font-sans);
background:
linear-gradient(90deg, rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
}
main {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
}
.maintenance-card {
width: min(100%, 560px);
overflow: hidden;
border: 1px solid rgba(31, 42, 59, .14);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow-raised);
}
.status-ribbon {
height: 12px;
background:
linear-gradient(90deg, var(--pokemon-red) 0 28%, var(--line-strong) 28% 34%, var(--surface) 34% 66%, var(--line-strong) 66% 72%, var(--pokemon-blue) 72% 100%);
}
.content {
padding: clamp(28px, 6vw, 48px);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.mark {
position: relative;
width: 48px;
height: 48px;
flex: 0 0 auto;
overflow: hidden;
border: 4px solid var(--line-strong);
border-radius: 50%;
background:
linear-gradient(180deg, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--surface) 55% 100%);
box-shadow: 0 4px 0 rgba(31, 42, 59, .2);
}
.mark::after {
content: "";
position: absolute;
inset: 13px;
border: 4px solid var(--line-strong);
border-radius: 50%;
background: var(--surface);
}
.brand-name {
display: block;
color: var(--pokemon-yellow);
font-family: var(--font-display);
font-size: 2rem;
font-weight: 900;
line-height: .95;
-webkit-text-stroke: 2px var(--pokemon-blue-deep);
text-shadow: 2px 3px 0 var(--pokemon-blue);
}
.brand-subtitle {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: .78rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.status {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 7px 10px;
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 28%, var(--line));
border-radius: 8px;
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
color: var(--pokemon-blue-deep);
font-size: .82rem;
font-weight: 800;
line-height: 1.2;
text-transform: uppercase;
}
h1 {
margin: 20px 0 10px;
color: var(--ink);
font-size: 3rem;
font-weight: 900;
letter-spacing: 0;
line-height: 1.04;
}
p {
max-width: 38rem;
margin: 0;
color: var(--ink-soft);
font-size: 1.12rem;
line-height: 1.65;
}
.meter {
height: 12px;
margin-top: 30px;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--surface-soft);
}
.meter span {
display: block;
width: 70%;
height: 100%;
border-right: 1px solid rgba(31, 42, 59, .28);
background: linear-gradient(90deg, var(--pokemon-yellow) 0%, var(--pokemon-yellow-soft) 46%, var(--pokemon-blue) 100%);
}
@media (max-width: 520px) {
main {
align-items: stretch;
}
.maintenance-card {
align-self: center;
}
.brand {
align-items: flex-start;
}
.brand-name {
font-size: 1.65rem;
}
h1 {
font-size: 2rem;
}
p {
font-size: 1rem;
}
}
</style>
</head>
<body>
<main aria-labelledby="maintenance-title">
<section class="maintenance-card" aria-live="polite">
<div class="status-ribbon" aria-hidden="true"></div>
<div class="content">
<div class="brand">
<span class="mark" aria-hidden="true"></span>
<div>
<span class="brand-name">Pokopia</span>
<span class="brand-subtitle">Wiki</span>
</div>
</div>
<span class="status">Upgrading</span>
<h1 id="maintenance-title">Pokopia Wiki is upgrading</h1>
<p>We'll be online within 5 minutes.</p>
<div class="meter" aria-hidden="true"><span></span></div>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,45 @@
server {
listen 20015;
server_name _;
resolver 127.0.0.11 valid=5s ipv6=off;
location / {
auth_request /backend-health;
error_page 500 502 503 504 =503 /maintenance.html;
set $frontend_upstream http://frontend:20015;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 1s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_intercept_errors on;
proxy_pass $frontend_upstream;
}
location = /backend-health {
internal;
set $backend_upstream http://backend:3001/health;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_connect_timeout 1s;
proxy_read_timeout 1s;
proxy_pass $backend_upstream;
}
location = /maintenance.html {
internal;
root /usr/share/nginx/html;
add_header Cache-Control "no-store" always;
add_header Retry-After "300" always;
}
}