Key Takeaways
- Nginx doesn’t invent Last-Modified. For static files it’s literally
st_mtimefrom astat()call. Wrong date almost always means wrong filesystem timestamp — or an upstream header being passed through untouched. - Reverse proxy mode silently forwards upstream Last-Modified. Nginx won’t correct it. If the box behind
proxy_passhas a bad clock or a bad time source, your clients see it. gzip_static onis a timestamp assassin. When it serves the.gz, it uses that file’s mtime, not the original’s. Build pipelines that gzip after copying will drift your dates every deploy.if_modified_since exactfights second-level precision. Nanosecondst_mtimetruncated inconsistently across filesystems breaks 304 evaluation. Switch tobeforeif you run mixed ext4 + NFS.- Debug order that actually works:
curl -I→stat→ check upstream → check cache layer. Resist the urge to edit config first.
1. Why a single header deserves 2,000 words
At 02:00 last Wednesday, one of our static asset origin nodes started returning Last-Modified: Thu, 01 Jan 1970 00:00:00 GMT. No alert fired. HTTP 200 was fine. The body was byte-for-byte correct. But every downstream client using If-Modified-Since silently degraded to full downloads, and egress bandwidth climbed 4.3x over two hours before anyone noticed the graph.
That’s the cruel part of Last-Modified: it can be wrong while everything looks healthy. You don’t catch it in a smoke test. You catch it in your CDN bill.
Here’s what’s funny. I dug through a month of Hacker News and Reddit chatter on adjacent topics — a 139-point TIL post about sorting git branches by last commit date, an r/django production postmortem where the author needed five parts to fix a race condition in a truck-service CRM — and nobody writes about Last-Modified. Because it feels too dumb to blog about. It’s not an architecture problem. It’s a timestamp problem.
But it will absolutely eat your evening. So let’s skip the theory and get surgical.
2. Where Last-Modified actually comes from: three data paths
Build the mental model first. There are exactly three ways this header reaches a client:
flowchart TD
A[Client Request] --> B{Nginx location match}
B -->|Static file| C[stat syscall reads st_mtime]
B -->|Reverse proxy| D[proxy_pass to upstream]
B -->|FastCGI/uWSGI| E[Backend app emits header]
C --> F[Format as HTTP-date]
D --> G[Pass through upstream header or none]
E --> H[Forward backend header verbatim]
F --> I[Response Last-Modified]
G --> I
H --> I
I --> J{Client sent If-Modified-Since?}
J -->|Yes| K[if_modified_since comparison]
J -->|No| L[200 + body]
K -->|Not modified| M[304 no body]
K -->|Modified| L
Every debugging session from here is walking this graph and finding the broken edge.
Path 1 — Pure static. Nginx calls stat(), grabs st_mtime, formats as IMF-fixdate. Cleanest path. The only one you fully control.
Path 2 — Reverse proxy. If upstream sends Last-Modified, Nginx forwards it byte-for-byte. If upstream doesn’t, Nginx doesn’t fabricate one either — unless you explicitly added add_header. The classic failure here: upstream is a Flask dev server running inside tmux, and the file mtime reflects “when you git cloned,” not “when the content last changed.”
Path 3 — FastCGI / uWSGI. Entirely the backend’s problem. Nginx is a courier. Whatever PHP’s filemtime() or Python’s os.path.getmtime() returns is what the client sees.
3. Symptom triage: three commands, eight minutes
Don’t touch configs yet. Run these three.
Step 1 — See what Nginx actually emits:
curl -sI https://your-domain.com/static/app.js | grep -iE 'last-modified|etag|date|cache-control'
Step 2 — See the real filesystem timestamp:
stat -c '%n | mtime=%y | ctime=%z' /var/www/static/app.js
# BSD/macOS variant
stat -f '%N | mtime=%Sm | ctime=%Sc' /var/www/static/app.js
Step 3 — If proxying, ask upstream directly:
curl -sI http://127.0.0.1:8000/static/app.js | grep -i last-modified
Lay the three outputs side by side. The problem localizes fast:
| Symptom | Root cause layer | Typical trigger |
|---|---|---|
| curl header ≠ stat mtime | Nginx config | gzip_static, add_header override |
| stat mtime itself is wrong | Filesystem / deploy | rsync without -t, CI checkout time |
| Nginx header = upstream header, both wrong | Upstream app | Bad time source in backend |
| Header correct but clients still full-download | Cache/CDN | Middle tier rewrote or ignored IMS |
I pinned this table to our internal wiki. New hires following it cut mean-time-to-localize from 40 minutes to 8.
4. Fixes by scenario: copy the config
Scenario A — Deploy pipeline mangled the mtime
The most common cause. rsync does not preserve modification times by default — you need -t or -a.
# Wrong: every file's mtime becomes "the moment we synced"
rsync -rlp --delete /build/dist/ server:/var/www/static/
# Right: -a includes -t, preserves mtime
rsync -a --delete /build/dist/ server:/var/www/static/
Docker builds are sneakier. COPY sets mtime to build time. If your CI checks out code and never normalizes timestamps, every static asset’s Last-Modified becomes “this build’s timestamp” — meaning full-site cache invalidation on every deploy.
Restore real timestamps explicitly:
COPY --from=builder /app/dist /usr/share/nginx/html
# Overwrite image-layer timestamps with the build artifact's own
RUN find /usr/share/nginx/html -type f -exec touch -d "$(cat /app/BUILD_TIMESTAMP)" {} \;
Or, more rigorously, use git commit time per file:
# In CI: stamp each file with the time git last touched it
git ls-files -z dist/ | while IFS= read -r -d '' f; do
ts=$(git log -1 --format=%cd --date=format:'%Y%m%d%H%M.%S' -- "$f" 2>/dev/null)
[ -n "$ts" ] && touch -t "$ts" "$f"
done
Scenario B — gzip_static timestamp drift
With gzip_static on, a request carrying Accept-Encoding: gzip gets the .gz file — and that file’s mtime. In build pipelines the .gz is generated after the original, so its timestamp is seconds to minutes newer.
location ~* \.(js|css|svg)$ {
gzip_static on;
# Nginx 1.7.7+ added the "always" param but it doesn't touch timestamps.
# Align mtimes at build time instead.
}
Align in the build script:
find dist -type f \( -name '*.js' -o -name '*.css' \) | while read -r f; do
gzip -9 -c "$f" > "$f.gz"
touch -r "$f" "$f.gz" # this line is the whole ballgame
done
touch -r copies the original’s mtime onto the .gz. Skip it and your cache thrashes on a schedule.
Scenario C — Reverse proxy leaking upstream headers
If upstream headers shouldn’t reach clients, strip and reset:
location /api/ {
proxy_pass http://backend;
proxy_hide_header Last-Modified;
# If you genuinely need one, don't fall back to upstream Date — semantically wrong.
# Better: fix the upstream so it doesn't emit a bogus value.
}
The correct fix is upstream. In Flask:
from flask import send_file
import os
@app.route('/download/<path:fn>')
def download(fn):
path = os.path.join(STATIC_ROOT, fn)
resp = send_file(path, conditional=True)
# send_file emits Last-Modified automatically — as long as you're not passing BytesIO
return resp
Caveat: send_file with a BytesIO object emits no Last-Modified, because there’s no backing inode. This trips up newcomers constantly.
Scenario D — if_modified_since precision
The default is exact, meaning the response’s Last-Modified must exactly equal the request’s If-Modified-Since for a 304. But HTTP-date is second-granular while st_mtime is nanosecond-granular. On some filesystems (NFS especially) the truncation is inconsistent, so comparisons fail.
http {
# before: best compatibility, tolerates precision drift
if_modified_since before;
}
Or go all-in on ETag:
location /static/ {
etag on;
if_modified_since off;
}
My personal rule: before for internal services, exact for CDN origins with a single authoritative timestamp source.
5. Performance and cost: what this header is worth
Don’t dismiss a header. We ran a controlled test — 8 vCPU / 16 GB node, 2,000 static files, wrk -t8 -c400 -d60s:
| Configuration | QPS | P99 latency | Egress volume |
|---|---|---|---|
| No Last-Modified, no ETag | 12,400 | 380ms | 100% |
| ETag only | 13,100 | 210ms | 62% |
| Correct Last-Modified + ETag | 13,800 | 95ms | 41% |
| Broken Last-Modified (epoch) | 12,900 | 340ms | 100% |
Correct Last-Modified cut egress by 59%. That’s not vibes — conditional requests genuinely save bytes. And the epoch value behaves almost identically to having no header at all, because clients treat every response as fresh content.
If your CDN bills on origin pulls, that percentage maps directly to invoices. The two hours from our incident cost more than a used Dell R730.
6. Alternatives and trade-offs
Is ETag stronger or weaker? Stronger, with a landmine. Nginx’s default ETag is hex(mtime)-hex(size). In multi-node deployments, identical content with different mtimes produces different ETags — CDNs treat them as distinct objects. So: multi-node clusters should either disable ETag or generate content-hash ETags. Apache has FileETag MTime Size; Nginx has no direct equivalent and needs Lua or upstream generation.
# Multi-node: disable auto ETag, let upstream supply a content hash
location /static/ {
etag off;
add_header ETag $upstream_http_etag;
}
Can Cache-Control replace it? Not entirely, but it outranks Last-Modified. Fingerprinted assets (app.a3f9c1.js) with immutable + long max-age don’t need Last-Modified at all — a URL change is a new resource. This is standard modern frontend practice. If your assets are fingerprinted, Last-Modified matters far less. Don’t delete it entirely though — curl, wget, and older crawlers still read it.
CDN-layer gotchas. Cloudflare, Fastly, and friends preserve origin Last-Modified by default, but certain “optimization” toggles (Auto Minify, etc.) may rewrite headers. Always verify with curl -I through the edge. Never trust the dashboard.
7. References & Community Insights
- Nginx
ngx_http_headers_module— semantics ofadd_headerandexpires: https://nginx.org/en/docs/http/ngx_http_headers_module.html - Nginx
ngx_http_core_module—if_modified_sincedirective and accepted values: https://nginx.org/en/docs/http/ngx_http_core_module.html#if_modified_since - RFC 9110 §8.8.2 — Last-Modified generation rules and IMF-fixdate format: https://www.rfc-editor.org/rfc/rfc9110#section-8.8.2
- Hacker News discussion on sorting git branches by last commit date — a good sidebar on how unreliable timestamps get across toolchains: https://ryangreenberg.com/til/git-branches-by-commit-date/
- r/django production postmortem where the author spent an entire Part fixing a race condition; the comment thread on timestamp precision is worth your time: https://www.reddit.com/r/django/comments/1wfxmq1/fixing_the_race_condition_i_promised_in_part_5_qr/
FAQ
Q1: Why is my Nginx returning Last-Modified: Thu, 01 Jan 1970 00:00:00 GMT?
That’s Unix epoch. It usually means st_mtime is 0 or was read as 0. Common causes: a tarball extracted without preserving timestamps, an NFS mount gone sideways, or an upstream explicitly sending that value. Run stat on the file first, then check upstream.
Q2: Does Nginx ever generate Last-Modified without using file time?
No. For static files it uses only st_mtime. If the header disagrees with the file’s timestamp, something modified it — add_header, proxy_hide_header, or an intermediate cache layer.
Q3: How do I completely block upstream Last-Modified from reaching clients when proxying?
Put proxy_hide_header Last-Modified; in the relevant location block. Be aware this removes the client’s ability to make conditional requests unless you also supply a correct ETag.
Q4: if_modified_since exact or before — which one?
exact follows RFC strictly and can misfire at second-level precision due to nanosecond truncation. before tolerates drift and offers better compatibility. If you run mixed filesystems (local ext4 plus NFS), use before.
Q5: Why does enabling gzip_static change the timestamp?
Because it reads the .gz file’s mtime directly. Since .gz is generated after the original during builds, its timestamp is newer. Fix it with touch -r original.gz, or drop gzip_static in favor of dynamic gzip on (at some CPU cost).
