Implement shared Caddy setup with initializing and unavailable pages for various apps

- Refactor domain scripts for Ghost, Gitea, Jellyfin, Metabase, n8n, NocoDB, Open WebUI, OpenClaw, PicoClaw, Plausible, Signoz, Uptime Kuma, and Vaultwarden to utilize a common Caddy setup script.
- Introduce `caddy-setup.sh` for managing Caddy configurations and handling app initialization states.
- Create `initializing.html` and `unavailable.html` pages to provide user feedback during app deployment and downtime.
- Update domain handling logic to ensure seamless transitions between initializing and operational states.
- Enhance user experience by providing visual indicators for app status during setup and maintenance.
This commit is contained in:
lolwierd
2026-03-28 14:37:33 +05:30
parent 250730435a
commit ed19997ba0
19 changed files with 810 additions and 181 deletions

57
_common/DESIGN.md Normal file
View File

@@ -0,0 +1,57 @@
---
# DESIGN.md — Excloud App Status Pages
---
## The Concept
**"Control room calm."** These pages exist in a moment of vulnerability — someone just paid for something and is waiting for it to exist. The design absorbs anxiety by projecting quiet confidence. Not "please wait," but "we've got this."
## The Hook
Two different *symbols* that communicate state without words:
- **Initializing:** A radar-like pulse beacon — concentric rings expanding outward from a warm glowing core. It reads as "scanning, reaching, deploying" — active progress without a progress bar (which would be dishonest since we don't know percentages).
- **Unavailable:** Two vertical pause bars — the universal "paused" symbol. Instantly communicates "temporarily stopped, not broken." It's the difference between a dead screen and a deliberate pause.
Both use the same design language but are immediately distinguishable at a glance.
## Typography Rationale
System font stack. This is deliberate, not lazy — these pages need to load *instantly* on first paint with zero layout shift. A custom font would flash or delay, exactly when the user needs immediate reassurance. The system stack also feels "native" to the platform, like a real system notification rather than a marketing page pretending nothing is wrong.
Tight negative letter-spacing on headings (-0.02em) gives them weight without needing a heavier font. The all-caps labels (0.15em tracking) create structural hierarchy — they read as metadata, not prose.
## Color Rationale
Warm amber/copper accent (#c4956a for initializing, #b5874f for unavailable) against near-black. Why warm:
- Cool blues/greens say "corporate status page" — the thing you see when AWS is down and you're panicking. Wrong association.
- Warm amber says "campfire," "instrument panel," "something alive." It's the color of active signals, not error states.
- The unavailable page shifts slightly cooler/darker in its amber — still the same family, but perceptibly "dimmer," like a light that's been turned down, not off.
The backgrounds use extremely subtle texture — a grid for initializing (structured, "things are happening in order"), dots for unavailable (quieter, ambient). Both are masked to only appear in the center, creating depth without clutter.
## Layout / Structure Rationale
Vertically centered, single column, no card/container. The content floats in space rather than sitting in a box. Cards create a boundary between "the page" and "the content" — here the content IS the page. There's nowhere else to look, nothing else to do. The design embraces that.
The status bar at the bottom (pill-shaped, border, dot + text) acts as a grounding element — it's the one piece that feels "UI" rather than "page," giving the user something concrete to anchor to. "This page refreshes automatically" is the key information and it's given its own component.
## What Was Rejected
- **Progress bars / steps.** Considered showing "Step 1: Pulling images, Step 2: Starting containers" etc. Rejected because (a) we don't have real-time progress data from inside the VM, so it would be fake, and (b) non-technical users don't care about docker pulls. Honesty over theater.
- **Large app icons/logos.** Considered fetching and displaying the app's icon. Rejected — we don't have guaranteed access to icons at the Caddy level (it's static HTML), and a missing/broken image would undermine trust more than no image at all.
- **Animated gradient backgrounds.** The obvious "premium loading screen" move. Rejected — it reads as marketing rather than infrastructure. These pages should feel like part of the *platform*, not a splash screen.
- **Any JavaScript.** Could have done smoother animations or real-time status checks via JS. Rejected — pure CSS keeps the page weight near zero, works with CSP restrictions, and the meta-refresh approach is honest about what's happening (polling, not streaming).
## Tone & Texture
The initializing page should feel like watching a satellite deploy — calm, methodical, the machinery is working. The grid texture reinforces "structured process."
The unavailable page should feel like a brief intermission — the lights are dimmed, not off. The dot texture is softer, the pause symbol is immediately readable, and "taking a moment" is human language, not tech jargon.
Both avoid the word "error." Neither page is an error. One is a process, the other is a pause.
## The Small Detail Nobody Will Notice
The beacon rings on the initializing page are staggered by 0.8s, not the typical 0.5s. This creates a slower, more deliberate rhythm — closer to breathing than to a loading spinner. It subconsciously signals "this is supposed to take a minute" rather than "something is stuck."

130
_common/caddy-setup.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
#
# Shared Caddy setup with initializing/unavailable pages.
#
# Usage in domain.sh:
# source /var/excloud/scripts/caddy-setup.sh
# setup_initializing_page "$DOMAIN" "$APP_NAME" "$APP_DIR/$APP_NAME"
# ... start containers ...
# wait_and_switch_to_proxy "$DOMAIN" "$APP_UPSTREAM_PORT" "$APP_DIR/$APP_NAME" &
#
# For apps with custom Caddyfile (e.g. signoz with multi-route), use:
# write_loading_pages "$APP_NAME" "$APP_DIR/$APP_NAME"
# setup_initializing_page "$DOMAIN" "$APP_NAME" "$APP_DIR/$APP_NAME"
# ... start containers ...
# wait_and_switch_to_proxy "$DOMAIN" "$APP_UPSTREAM_PORT" "$APP_DIR/$APP_NAME" "$CUSTOM_CADDYFILE_CONTENT" &
SCRIPT_DIR_CADDY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
write_loading_pages() {
local app_name="$1"
local app_dir="$2"
local display_name
display_name="$(echo "$app_name" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2)}1')"
mkdir -p "${app_dir}/.excloud"
cp "${SCRIPT_DIR_CADDY}/initializing.html" "${app_dir}/.excloud/initializing.html"
cp "${SCRIPT_DIR_CADDY}/unavailable.html" "${app_dir}/.excloud/unavailable.html"
sed -i "s/APP_DISPLAY_NAME/${display_name}/g" "${app_dir}/.excloud/initializing.html"
sed -i "s/APP_DISPLAY_NAME/${display_name}/g" "${app_dir}/.excloud/unavailable.html"
}
# Phase 1: Serve the initializing page immediately via Caddy file_server.
setup_initializing_page() {
local domain="$1"
local app_name="$2"
local app_dir="$3"
write_loading_pages "$app_name" "$app_dir"
cat > /etc/caddy/Caddyfile <<EOF
https://${domain} {
root * ${app_dir}/.excloud
rewrite * /initializing.html
file_server
}
EOF
systemctl enable caddy
systemctl reload caddy
echo "Caddy serving initializing page for ${app_name}"
}
# Phase 2 (runs in background): Poll the upstream port, then switch Caddy to reverse_proxy.
# Accepts an optional 4th argument with custom Caddyfile content for apps like signoz.
wait_and_switch_to_proxy() {
local domain="$1"
local port="$2"
local app_dir="$3"
local custom_caddyfile="${4:-}"
# Wait for the app to respond (up to 20 minutes)
local start_time
start_time="$(date +%s)"
while ! curl -fsS -o /dev/null "http://127.0.0.1:${port}" 2>/dev/null; do
if [ $(( $(date +%s) - start_time )) -ge 1200 ]; then
echo "App did not become ready within 20 minutes" >&2
break
fi
sleep 5
done
# Switch to reverse proxy with handle_errors for unavailable page
if [ -n "$custom_caddyfile" ]; then
echo "$custom_caddyfile" > /etc/caddy/Caddyfile
else
cat > /etc/caddy/Caddyfile <<EOF
https://${domain} {
reverse_proxy 127.0.0.1:${port}
handle_errors {
root * ${app_dir}/.excloud
rewrite * /unavailable.html
file_server
}
}
EOF
fi
touch "${app_dir}/.excloud/.ready"
systemctl reload caddy
echo "App is ready — Caddy switched to reverse proxy"
}
# Quick domain swap for an already-running app. Skips the initializing page
# and background watcher — just updates the Caddyfile and reloads.
# Accepts an optional 4th argument with custom Caddyfile content.
switch_domain() {
local domain="$1"
local port="$2"
local app_dir="$3"
local custom_caddyfile="${4:-}"
write_loading_pages "$(basename "$app_dir")" "$app_dir"
if [ -n "$custom_caddyfile" ]; then
echo "$custom_caddyfile" > /etc/caddy/Caddyfile
else
cat > /etc/caddy/Caddyfile <<EOF
https://${domain} {
reverse_proxy 127.0.0.1:${port}
handle_errors {
root * ${app_dir}/.excloud
rewrite * /unavailable.html
file_server
}
}
EOF
fi
systemctl reload caddy
echo "Domain switched to ${domain}"
}
# Returns 0 if the app has completed first-time setup, 1 otherwise.
is_app_ready() {
local app_dir="$1"
[ -f "${app_dir}/.excloud/.ready" ]
}

228
_common/initializing.html Normal file
View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="5">
<title>APP_DISPLAY_NAME — Deploying</title>
<style>
/* --- Reset & Base --- */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--surface: #0a0a0a;
--surface-raised: #111111;
--surface-border: #1a1a1a;
--text-primary: #e8e4df;
--text-secondary: #6b6560;
--text-dim: #3a3632;
--accent: #c4956a;
--accent-glow: rgba(196, 149, 106, 0.08);
--pulse-ring: rgba(196, 149, 106, 0.15);
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--surface);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
/* --- Ambient grid texture --- */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--surface-border) 1px, transparent 1px),
linear-gradient(90deg, var(--surface-border) 1px, transparent 1px);
background-size: 60px 60px;
opacity: 0.3;
mask-image: radial-gradient(ellipse 50% 50% at 50% 50%, black 20%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse 50% 50% at 50% 50%, black 20%, transparent 70%);
pointer-events: none;
}
/* --- Main content --- */
.deploy-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
max-width: 520px;
width: 100%;
}
/* --- Pulse beacon --- */
.beacon {
position: relative;
width: 64px;
height: 64px;
margin-bottom: 2.5rem;
}
.beacon::before,
.beacon::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
border: 1.5px solid var(--accent);
}
.beacon::before {
animation: beacon-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.beacon::after {
animation: beacon-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite 0.8s;
}
.beacon-core {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 20px var(--pulse-ring), 0 0 40px var(--accent-glow);
animation: core-breathe 2.4s ease-in-out infinite;
}
@keyframes beacon-ping {
0% { transform: scale(0.3); opacity: 0.8; }
100% { transform: scale(1.2); opacity: 0; }
}
@keyframes core-breathe {
0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
}
/* --- App label --- */
.app-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.75rem;
opacity: 0.9;
}
/* --- Heading --- */
.heading {
font-size: clamp(1.4rem, 4vw, 1.8rem);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
text-align: center;
margin-bottom: 1rem;
color: var(--text-primary);
}
/* --- Body copy --- */
.body-text {
font-size: 0.95rem;
line-height: 1.7;
color: var(--text-secondary);
text-align: center;
max-width: 380px;
}
/* --- Status bar --- */
.status-bar {
margin-top: 2.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: 100px;
}
.status-dot {
width: 6px;
height: 6px;
background: var(--accent);
border-radius: 50%;
animation: status-blink 1.5s ease-in-out infinite;
}
@keyframes status-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
/* --- Footer --- */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1.5rem;
text-align: center;
}
.footer-text {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
/* --- Responsive --- */
@media (max-width: 480px) {
.deploy-container { padding: 1.5rem; }
.beacon { width: 52px; height: 52px; margin-bottom: 2rem; }
}
/* --- Reduced motion --- */
@media (prefers-reduced-motion: reduce) {
.beacon::before, .beacon::after, .beacon-core, .status-dot {
animation: none;
}
.beacon::before { transform: scale(0.8); opacity: 0.3; }
.beacon::after { transform: scale(1.1); opacity: 0.15; }
.beacon-core { opacity: 0.9; }
.status-dot { opacity: 1; }
}
</style>
</head>
<body>
<div class="deploy-container">
<div class="beacon">
<div class="beacon-core"></div>
</div>
<div class="app-label">Deploying</div>
<h1 class="heading">APP_DISPLAY_NAME is on its way</h1>
<p class="body-text">Your app is being installed and configured. This usually takes a few minutes.</p>
<div class="status-bar">
<div class="status-dot"></div>
<span class="status-text">Setting up &mdash; this page refreshes automatically</span>
</div>
</div>
<footer class="footer">
<span class="footer-text">Powered by Excloud</span>
</footer>
</body>
</html>

222
_common/unavailable.html Normal file
View File

@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="5">
<title>APP_DISPLAY_NAME — Unavailable</title>
<style>
/* --- Reset & Base --- */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--surface: #0a0a0a;
--surface-raised: #111111;
--surface-border: #1a1a1a;
--text-primary: #e8e4df;
--text-secondary: #6b6560;
--text-dim: #3a3632;
--caution: #b5874f;
--caution-dim: rgba(181, 135, 79, 0.06);
--caution-border: rgba(181, 135, 79, 0.12);
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--surface);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
/* --- Subtle noise/grain feel via dots --- */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle at center, var(--surface-border) 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.25;
mask-image: radial-gradient(ellipse 40% 40% at 50% 50%, black 10%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse 40% 40% at 50% 50%, black 10%, transparent 70%);
pointer-events: none;
}
/* --- Main content --- */
.unavail-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
max-width: 520px;
width: 100%;
}
/* --- Pause indicator (two vertical bars — universal "paused" symbol) --- */
.pause-icon {
display: flex;
gap: 6px;
margin-bottom: 2.5rem;
opacity: 0.85;
}
.pause-bar {
width: 6px;
height: 32px;
background: var(--caution);
border-radius: 2px;
animation: bar-fade 3s ease-in-out infinite;
}
.pause-bar:nth-child(2) {
animation-delay: 0.3s;
}
@keyframes bar-fade {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* --- Caution banner --- */
.caution-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.85rem;
background: var(--caution-dim);
border: 1px solid var(--caution-border);
border-radius: 100px;
margin-bottom: 1.25rem;
}
.caution-badge-dot {
width: 5px;
height: 5px;
background: var(--caution);
border-radius: 50%;
}
.caution-badge-text {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--caution);
}
/* --- Heading --- */
.heading {
font-size: clamp(1.4rem, 4vw, 1.8rem);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
text-align: center;
margin-bottom: 1rem;
color: var(--text-primary);
}
/* --- Body copy --- */
.body-text {
font-size: 0.95rem;
line-height: 1.7;
color: var(--text-secondary);
text-align: center;
max-width: 380px;
}
/* --- Status bar --- */
.status-bar {
margin-top: 2.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: 100px;
}
.status-dot {
width: 6px;
height: 6px;
background: var(--caution);
border-radius: 50%;
animation: status-blink 1.5s ease-in-out infinite;
}
@keyframes status-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
/* --- Footer --- */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1.5rem;
text-align: center;
}
.footer-text {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
/* --- Responsive --- */
@media (max-width: 480px) {
.unavail-container { padding: 1.5rem; }
.pause-bar { height: 26px; width: 5px; }
.pause-icon { margin-bottom: 2rem; }
}
/* --- Reduced motion --- */
@media (prefers-reduced-motion: reduce) {
.pause-bar, .status-dot {
animation: none;
}
.pause-bar { opacity: 0.8; }
.status-dot { opacity: 1; }
}
</style>
</head>
<body>
<div class="unavail-container">
<div class="pause-icon">
<div class="pause-bar"></div>
<div class="pause-bar"></div>
</div>
<div class="caution-badge">
<div class="caution-badge-dot"></div>
<span class="caution-badge-text">Temporarily unavailable</span>
</div>
<h1 class="heading">APP_DISPLAY_NAME is taking a moment</h1>
<p class="body-text">The app is restarting or undergoing brief maintenance. It should be back shortly.</p>
<div class="status-bar">
<div class="status-dot"></div>
<span class="status-text">Recovering &mdash; this page refreshes automatically</span>
</div>
</div>
<footer class="footer">
<span class="footer-text">Powered by Excloud</span>
</footer>
</body>
</html>