Automatic maintenance page with Caddy during Docker deploys
Use Caddy's handle_errors to catch the 502 Bad Gateway that appears while a container rebuilds and serve a static maintenance page automatically, with no manual on/off switch.
On this page
- Why this approach works well
- Folder layout
- 1. Create a standalone maintenance HTML page
- 2. Mount the maintenance folder into the Caddy container
- 3. Configure Caddy to catch the 502
- 4. Restart Caddy to pick up the new volume mount
- 5. Test the maintenance page
- Sample maintenance.html (using the Tailwind CDN)
- Troubleshooting
When you deploy an app with Docker behind Caddy as the reverse proxy, every
docker compose up --build or container restart causes a short window of downtime. If
someone visits during that window, Caddy cannot reach the container (the upstream) and
returns its default 502 Bad Gateway - a blank, ugly page.
This guide intercepts that 502 and automatically shows a professional HTML maintenance page instead. Once the container is back up, Caddy sends requests to the real app again, with no action on your part.
It assumes Caddy already runs in Docker from ~/services/caddy (see
Set up Caddy as a reverse proxy on a VPS with Docker).
Replace example.com and my-app-container with your own domain and container name.
Why this approach works well
- A more professional user experience (UX): visitors know the system is being upgraded, instead of getting a bad impression or assuming the site is "down".
- Everything managed in one place: the Caddy config, the maintenance HTML file and
docker-compose.ymlall live in~/services/caddy, which makes it easy to back up the config (to GitHub, for example) or move the whole stack to another VPS. - Automated recovery: there is no maintenance switch to flip by hand (as with older Nginx setups). Caddy keeps retrying the proxy; as soon as the Docker build finishes and the container is available again, the 502 disappears and Caddy routes requests back to the site automatically and smoothly.
- Light on resources: you do not need a Blue-Green Deployment setup that eats VPS memory. It is a good fit for small apps, or any app that can tolerate a short maintenance window.
Folder layout
The Caddy folder on your server will look like this:
~/services/caddy/
+-- docker-compose.yml
+-- Caddyfile
+-- maintenance/
+-- maintenance.html1. Create a standalone maintenance HTML page
The maintenance page must be a standalone static HTML page, because the app server (Next.js, an API, etc.) is down at this point.
First, create a folder for the page inside the Caddy config folder (keeping everything managed in one place):
mkdir -p ~/services/caddy/maintenanceNext, create maintenance.html (copy the sample HTML at the end of this guide and
paste it in):
nano ~/services/caddy/maintenance/maintenance.html2. Mount the maintenance folder into the Caddy container
Caddy runs inside an isolated Docker container, so it cannot see folders on the host OS. You need to mount the folder into the container.
Open Caddy's docker-compose.yml:
nano ~/services/caddy/docker-compose.ymlFind the volumes section of the caddy service and add the line that mounts the
maintenance folder:
services:
caddy:
image: caddy:latest # or alpine, whichever you use
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
# The crucial line: mount the host folder as Caddy's root folder
- ./maintenance:/var/www/maintenance
- caddy_data:/data
- caddy_config:/config3. Configure Caddy to catch the 502
Open the Caddyfile:
nano ~/services/caddy/CaddyfileAdd a handle_errors block inside your domain block. Here is a complete config for
example.com:
example.com {
# 1. Send normal traffic to your Docker app (frontend/API)
reverse_proxy my-app-container:3000
# 2. This block handles errors coming from reverse_proxy or from Caddy itself
handle_errors {
# Match only 502 (Bad Gateway - upstream timeout, container down or restarting)
@502 {
expression {err.status_code} == 502
}
# When the 502 matcher hits, serve the static file with this block:
handle @502 {
# Point Caddy at the static folder mounted in step 2
root * /var/www/maintenance
# Rewrite every failed request to this one HTML file
rewrite * /maintenance.html
# Caddy acts as a file server and returns the file to the client
file_server
}
}
}4. Restart Caddy to pick up the new volume mount
Because you just edited docker-compose.yml (to mount a new volume), you need to
recreate the Caddy container entirely, rather than running the usual soft config
reload.
cd ~/services/caddy
docker compose up -d caddy
# Or, to be safer: docker compose down && docker compose up -d5. Test the maintenance page
You can now simulate maintenance right on the host:
- Stop the main app (the frontend, for example):
docker stop my-app-container. - Refresh the site in your browser: instead of the default 502 page, you get the HTML maintenance page in the colors of your own brand design.
- Bring the app back with
docker compose up -d. Refresh a few seconds later and the site works normally again.
Sample maintenance.html (using the Tailwind CDN)
So the site still looks good during maintenance, copy this HTML into
maintenance.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Under Maintenance</title>
<!-- Load Tailwind from the CDN for quick styling -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@300;400;500;600;700&display=swap');
body { font-family: 'Be Vietnam Pro', sans-serif; background-color: #FAFAFA; }
.text-primary { color: #D4AF37; }
.bg-primary { background-color: #D4AF37; }
.halo-effect { box-shadow: 0 0 50px rgba(212, 175, 55, 0.15); }
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full bg-white rounded-2xl shadow-sm border border-gray-100 p-8 md:p-12 text-center halo-effect">
<!-- Logo / artwork - swap in your project's own -->
<div class="flex justify-center mb-8">
<svg class="w-16 h-16 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 2v20m-9.6-9.6l19.2 0m-17.5-6.5l15.8 13.1m-15.8 0l15.8-13.1 M12 22c5.522 0 10-4.478 10-10S17.522 2 12 2 2 6.478 2 12s4.478 10 10 10z"></path>
</svg>
</div>
<h1 class="text-3xl md:text-3xl font-bold text-gray-800 mb-4 tracking-tight">
The System Is Being Upgraded
</h1>
<p class="text-gray-500 mb-2 font-medium">(System is under maintenance)</p>
<div class="h-px bg-gray-100 w-24 mx-auto my-6"></div>
<div class="space-y-4 text-gray-600">
<p>The system is currently undergoing automated maintenance and a data update. Please wait, all services are expected to be back within a few minutes.</p>
</div>
<div class="mt-10">
<button onclick="window.location.reload()" class="bg-primary hover:bg-[#C5A030] text-white font-medium py-3 px-8 rounded-full transition duration-300 shadow-md">
Reload Page
</button>
</div>
</div>
</body>
</html>Troubleshooting
| Symptom | Fix |
|---|---|
| Still seeing the blank 502 page after editing the Caddyfile | The maintenance folder is not mounted into the container - check the volume line from step 2, then recreate the container with docker compose up -d caddy; a caddy reload alone is not enough |
| The maintenance page shows up but looks broken | The page must be standalone static HTML: load CSS/JS from a CDN or inline it, never from the app that is down |
| The maintenance page never goes away | The app container is not back up - Caddy only routes requests to the app once the upstream is available, so check the app's logs |