DEV Community

Your asset URL returned 200. It was serving index.html.

Your asset URL returned 200. It was serving index.html.

I run a small private ops portal on nginx that holds the output of my scheduled jobs: reports, generated images, PDFs, drafts. A Python CLI called publicar copies each file into media/<publication-id>/, writes a JSON record that points at it, and rebuilds a manifest the feed reads. Upload verification, for a while, was one line of curl asking for the status code. It came back 200 and I moved on.

Then I asked the server a more precise question and got two different answers in the same minute, for two paths on the same host:

/media/20261026-1926-o-id-chega-arredondado-26-10-16h/01-slide-1-e6347173.png
  200  content-type: image/png    hash matches the local file

/media/pasta-que-nao-existe/relatorio.pdf
  200  content-type: text/html    hash does not match anything
Enter fullscreen mode Exit fullscreen mode

The second path never existed. No PDF was ever generated with that name, no directory was created for it. It answered 200 because it was answering with the application shell.

Where the 200 comes from

The portal is a single-page app, and the vhost has the line that every single-page app needs:

location / {
    try_files $uri $uri/ /index.html;
}
Enter fullscreen mode Exit fullscreen mode

That fallback is correct for routes. It is what lets /publicacao.html?id=abc render instead of returning 404 when the router owns the URL. The same line also decides what happens to a file request nobody planned for: the file is missing, so nginx serves index.html with status 200.

Matching in nginx runs by location, and my vhost has extension-specific blocks. A missing .png was correctly a 404, and so was a missing .json, because both extensions have their own location and nginx serves them from disk. Video, PDF, zip and CSV had no location at all. Those requests fell into location /, took the fallback, and returned HTML under a 200.

That is why the failure looked intermittent: identical code, identical record, different answer depending on the extension. A <video> element pointed at a missing .mp4 receives HTML and shows a broken player. A download link saves a file called index.html.

Immutable caching makes the filename part of the contract

Measured on the same request that returned the real image:

cache-control: public, max-age=31536000, immutable
Enter fullscreen mode Exit fullscreen mode

One year, and immutable tells the browser not to revalidate even on a reload. That header is a promise about the bytes at that URL, and the only way to keep it is to make sure the bytes at that URL never change. So the publisher names every file with a hash of its own content:

bruto = src.read_bytes()
digest = hashlib.sha256(bruto).hexdigest()[:8]
nome = f"{indice:02d}-{slug(src.stem, 32)}-{digest}{ext}"
Enter fullscreen mode Exit fullscreen mode

The numeric prefix is for ordering inside the publication page. The hash is what identifies the file. Replace the cover image and the URL changes, so the new bytes reach everybody immediately, including the reader with the old page still open. Name it by position instead, 01-capa.png, and the old image keeps being served for a year to anyone who already cached it. I lost a favicon that way once: a 404 got cached at the edge for a year, which is the same mistake in the other direction.

Updates append, they do not replace

--atualizar on this CLI starts from what the record already has:

midias = list(anterior.get("midias", []))
for spec in (args.midia or []) + (args.adicionar_midia or []):
    midias.append(copiar_midia(origem, pub_id, len(midias) + 1, legenda, tipo))
Enter fullscreen mode Exit fullscreen mode

Appending is the behaviour I want when a report gets its PDF attached an hour after the text went out. It is the wrong behaviour for a correction. Ask to replace slide two of a six-slide piece and the record ends up holding both versions: eleven entries, five of them the piece you thought you had replaced, and the page renders all of them in order.

The deduplication rule follows from the naming scheme. Two paths that share the content hash are the same image, because the hash was computed from the bytes. Filter by the numeric prefix and it works once: the next update renumbers everything, so the prefix you excluded no longer points at the copy you meant to drop.

The config in the repository is not the config on the server

There is a script in the repo that adds the missing piece. It inserts a /media/ location with try_files $uri =404 and the immutable header, idempotently, and it is correct code.

grep -c "/media/" /etc/nginx/sites-available/central_operacoes
0
Enter fullscreen mode Exit fullscreen mode

Zero. The generator lives in the repository, the nginx config lives in /etc, and nothing compares the two. Git shows a clean tree, the script passes a quick read, and the server keeps answering with the configuration it loaded weeks ago. An /etc file outside version control has no diff to review, so the verification has to read the running system: nginx -T prints the configuration nginx actually parsed, including everything pulled in by include, which is more than the file you edited.

add_header does not inherit

The same location would have carried the cache header and the 404, and it would have introduced a quieter problem. In nginx, a location that declares its own add_header stops inheriting the headers set at the server level. No warning, no error, and the headers simply stop appearing on responses from that location. HSTS disappears, along with the X-Content-Type-Options and X-Frame-Options lines I set once and stopped thinking about.

The generator includes the shared security snippet for exactly that reason:

location ^~ /media/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    include /etc/nginx/snippets/central_operacoes_security.conf;
    etag on;
    expires 1y;
    try_files $uri =404;
}
Enter fullscreen mode Exit fullscreen mode

Any new location that sets a header needs that include. The mistake is invisible in a screenshot and obvious in a security scan, which is a gap that can last a long time.

One function that answers the real question

A status code is a claim by the server. The bytes are the fact. This function checks both, plus the identity of the file, and it is what told me the PDF path was fake:

def check(url, local):
    """Confirms the URL serves the local file, not a fallback page."""
    base = ["curl", "-s", "-u", AUTH, "--resolve", "agentjj.com.br:443:127.0.0.1"]
    meta = subprocess.run(base + ["-o", "/dev/null", "-w", "%{http_code} %{content_type}", url],
                          capture_output=True, text=True, check=True).stdout.split()
    status, ctype = meta[0], meta[1] if len(meta) > 1 else ""
    corpo = subprocess.run(base + [url], capture_output=True, check=True).stdout
    igual = hashlib.sha256(corpo).hexdigest() == hashlib.sha256(open(local, "rb").read()).hexdigest()
    return status, ctype, igual
Enter fullscreen mode Exit fullscreen mode

Run against the two paths, it returns ('200', 'image/png', True) and ('200', 'text/html', False). The first tuple says the file is there and it is the file I uploaded. The second says the server answered successfully with something else entirely.

The three fields earn their place. The status tells you the request was routed. The content type tells you what kind of thing came back, which is where a fallback page gives itself away. The hash tells you it is your file and not a stale copy under the same name. Checking only the first is the version of this test that passes while the page is broken, and I have shipped that version more than once.

What I would check first next time

Read the content type before trusting a 200, especially on a server with a single-page fallback. Confirm the running configuration instead of the file you wrote, because a generator that never executed looks exactly like a generator that worked. And when caching is immutable, treat the filename as part of the API, since the URL has to change when the content changes.

Se esse tipo de armadilha aparece no seu projeto, comenta aí qual foi: servidor que responde 200 com página de erro, cache que serve arquivo velho, ou configuração que só existe no repositório. Gosto de comparar com o que já quebrou por aqui.

Top comments (0)