You rewrite a page. You copy it to the server. You load it and get the old one. Half an hour later you still get the old one. Nothing is wrong with the server. Your web server never told the browser how long to keep that page, so the browser decided for itself.
The page I rewrote was a dashboard. It read two data files. One of those files got retired along with the rewrite.
So the old page printed an error where that data goes.
The other panel read a file that still exists. That panel kept working. Live numbers, updating on its own timer, right underneath the error.
A dead server does not do that. A dead server gives you nothing.
Part live and part broken is the signature of a cached shell. The HTML is old. The data it can still reach is new. Suspect the page before you suspect the data.
Ask the server what it is actually sending with command curl -s https://example.com/ | grep -o "<title>.*</title>"
Then read the headers with command curl -sI https://example.com/
You want three things to agree.
Last-Modified matches when you deployed.Content-Length matches the new file on disk.Check the size on the server with command stat -c %s /srv/yoursite/index.html
If all three agree, stop looking at the server. It is doing its job. The problem is between the server and the reader.
file_server sends an ETag. It sends Last-Modified. It does not send Cache-Control.
No Cache-Control does not mean do not cache.
It means the browser gets to guess.
Browsers guess with heuristic caching. They look at how long ago the file was last modified, and keep it for a fraction of that age. A file untouched for a month gets held onto for a good while. That is sensible for a logo. It is wrong for a page whose entire job is being current.
One line, inside the block for that site only.
dashboard.example.com {
header Cache-Control "no-cache"
root * /srv/dashboard
file_server
}
Do not put it on the whole server. Your static pages and images want to be cached. Only the pages that must be current need this.
This is the part that gets remembered backwards, and the names are the reason.
no-store means never keep a copy. Every single load pulls the whole file down again.
no-cache means keep a copy, but ask before you use it.
Think of a library book you are allowed to keep on your desk. no-store makes you walk back to the shelf and carry the book over every time you want to read a page. no-cache lets the book live on your desk, but you have to phone the librarian first and ask whether a new edition came out. Most of the time the answer is no, and you read the copy already sitting in front of you.
So no-cache costs you a round trip. It does not cost you the file.
Confirm that is what you got. Read the ETag with command curl -sI https://example.com/
Then hand it back with command curl -s -o /dev/null -w "%{http_code}\n" -H 'If-None-Match: "your-etag-here"' https://example.com/
A 304 is the answer you want. The browser asked, the server said nothing changed, and no page body crossed the wire.
One Caddy usually fronts a lot of sites. A broken config file takes every one of them down together.
Test the new file in a throwaway container first with command docker run --rm -v /tmp/Caddyfile.new:/etc/caddy/Caddyfile:ro caddy:2 caddy validate --config /etc/caddy/Caddyfile
You are looking for Valid configuration on the last line. Everything above it is startup noise.
Keep the old one with command cp /opt/caddy/Caddyfile /opt/caddy/Caddyfile.bak-$(date +%s)
Swap the new file in. Then restart the container with command docker restart caddy
Restart, not reload. A reload can report success and quietly leave the old config running, which is the exact failure you are already trying to get out of.
Then curl every other site on that proxy and confirm they still answer. A login redirect is a 302 and is fine. A 000 or a timeout is not.
This is the mistake that let it through, and it was mine.
I checked the rewrite by copying the files to my own machine, serving them there, and taking a screenshot. The screenshot was perfect. Every row correct, every number live.
It proved the page was written properly. It could not have caught this. Nothing about a local copy involves the reverse proxy, the response headers, or a cache.
Load the real URL. In a real browser. Best in one that has never seen the site before.
A private window is the cheap version of that, and it takes about four seconds.
https://caddyserver.com/docs/caddyfile/directives/header
https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control