Runbook: Troubleshooting a blank or 403 static website served via Mailcow's nginx

What happened

The site stopped working because the original /opt/sites/example.org directory was renamed to /opt/sites/example.org-old, and a new /opt/sites/example.org directory was created in its place.

Docker bind mounts attach to a specific inode on the filesystem, not just a path name. When you renamed the original directory and created a new one at the same path, the new directory had a different inode. The running nginx container was still bound to the old inode (now at -old), so from inside the container /opt/sites/example.org appeared completely empty — even though the files were clearly visible on the host.

A simple nginx -s reload cannot fix this because the volume mount is managed by Docker, not nginx. Only a full container restart causes Docker to re-resolve the path and bind to the correct inode.


Symptoms

  • Site shows 403 Forbidden or a blank/old page after updating files
  • ls -la /opt/sites/example.org/ on the host shows files correctly
  • docker exec mailcowdockerized-nginx-mailcow-1 ls -la /opt/sites/example.org/ shows total 0 — directory appears empty inside the container
  • docker inspect mailcowdockerized-nginx-mailcow-1 | grep -A 10 "Mounts" shows the mount is missing or stale

Diagnosis steps

1. Check files exist on the host:

ls -la /opt/sites/example.org/

2. Find the correct nginx container name:

docker ps | grep nginx

3. Check files are visible inside the container:

docker exec mailcowdockerized-nginx-mailcow-1 ls -la /opt/sites/example.org/

4. Check the mount is present and correct:

docker inspect mailcowdockerized-nginx-mailcow-1 | grep -A 10 "Mounts"

5. Check nginx config is valid:

docker exec mailcowdockerized-nginx-mailcow-1 nginx -t

6. Check nginx logs for errors:

docker logs mailcowdockerized-nginx-mailcow-1 --tail=50

Fix

If the container can’t see the files, a full mailcow restart is required to force Docker to re-read the override file and re-bind the volume mounts:

cd /opt/mailcow-dockerized
docker compose down
docker compose up -d

:warning: This causes a brief outage of all mailcow services (email, nginx, SOGo etc) — typically less than two minutes.

After restart, confirm the mount is now correctly bound:

docker inspect mailcowdockerized-nginx-mailcow-1 | grep -A 5 "example.org"

And confirm the files are visible inside the container:

docker exec mailcowdockerized-nginx-mailcow-1 ls -la /opt/sites/example.org/

Then visit https://example.org to confirm the site is back up.


How to avoid this in future

When updating the site, never rename and recreate the directory. Instead, update files in place:

# Good — updates files without touching the directory inode
cp /path/to/new/index.html /opt/sites/example.org/index.html

# Also fine — removes and replaces individual files
rm /opt/sites/example.org/index.html
cp /path/to/new/index.html /opt/sites/example.org/

# Bad — breaks the Docker bind mount
mv /opt/sites/example.org /opt/sites/example.org-old
mkdir /opt/sites/example.org

If you do need to back up the old site first, copy rather than move:

# Safe backup that preserves the original directory and its inode
cp -r /opt/sites/example.org /opt/sites/example.org-old
# then update files in place