Skip to content

Re-enable X-Accel-Redirect for post images (root-cause why prod nginx rejects the internal redirect) #827

Description

@wintermeyer

TL;DR Production serves post images with send_file (the app streams the bytes) because the more efficient X-Accel-Redirect handoff to nginx silently fails on prod: nginx returns its bare internal 404 instead of streaming the file, even though the internal_post_images location is present and correct and the files are readable by the nginx user. This issue tracks root‑causing that nginx behavior so we can switch back to :accel_redirect (let nginx push the bytes) if we want the efficiency. Low priority — send_file works fine at current scale.

Background: how post images are served

Post images are auth‑proxied: every byte goes through GET /post_images/:token/:version (VutuvWeb.PostImageController), so a post's audience (deny‑model, Vutuv.Posts.visible_to?/2) guards its images too. The controller supports two serving modes (config :vutuv, :post_image_serving, read in serve/3, lib/vutuv_web/controllers/post_image_controller.ex:73):

  • :send_file — the app reads the file and streams it itself (sendfile syscall); nginx proxies it like any response. Current prod value (config/runtime.exs:70), and the dev/test default.
  • :accel_redirect — the app returns 200 with an empty body and a header X-Accel-Redirect: /internal_post_images/<token>/<version>.avif; nginx is expected to stream the file from an internal location. More efficient (nginx pushes the bytes, not the BEAM), but currently broken on prod — see below.
:send_file  (works)   browser ─▶ nginx ─▶ app reads file ─▶ bytes ─▶ nginx ─▶ browser
:accel      (broken)  browser ─▶ nginx ─▶ app: 200 + X-Accel-Redirect ─▶ nginx
                                           │ internal redirect to /internal_post_images/<token>/x.avif
                                           ▼
                                   internal location ──✗ nginx returns its bare `internal` 404
                                                          (never streams the file)

What we found (investigation 2026‑06‑29, prod host bremen2)

Switched prod to :accel_redirect, every post image (composer preview thumbnail, feed image, inline ![](…)) came back broken. Diagnosis:

  • The nginx vhost (/etc/nginx/sites-available/vutuv3, server block vutuv.de) does contain the location, it is correct, and the site is enabled:
    location ~ ^/internal_post_images/(?<token>[A-Za-z0-9_-]+)/(?<version>thumb|feed|large)\.avif$ {
        internal;
        alias /srv/vutuv3/post_images/$token/$version.avif;
    }
  • The .avif files exist on disk (/srv/vutuv3/post_images/<token>/) and are readable by www-data (the nginx user); the full path traverses; no AppArmor profile on nginx; /srv/vutuv3 is a real dir (no disable_symlinks); no proxy_ignore_headers.
  • The app emits the correct header — x-accel-redirect: /internal_post_images/<token>/<version>.avif — confirmed by test/vutuv_web/controllers/post_image_controller_test.exs ("production serving mode") and by the deployed BEAM.
  • A direct request to /internal_post_images/<token>/thumb.avif returns nginx's bare internal 404 — i.e. the regex matches and internal rejection works as designed.
  • For the real (X‑Accel) request, nginx returned that same bare internal 404 and never logged an open() attempt for the file (zero open() failed lines in vutuv.de-error.log, although that log has plenty for missing avatars — so nginx does log file‑open failures there). So nginx is treating the X‑Accel internal redirect as a forbidden external hit rather than serving it.

How the 404s were told apart (response‑size fingerprint)

response raw bytes gzipped
app controller 404 (full HTML page) 16,557 ~3,515
Phoenix NoRouteError 404 16,525 ~3,515
nginx bare internal 404 153 ~141

The browser logged 404 141 → the nginx bare internal 404, confirming the app behaved correctly and the failure is in nginx's handling of the X‑Accel redirect.

Hypotheses to test (none proven)

  • The one non‑standard thing in location /: proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; are set unconditionally (the websocket pattern applied to every request, not just upgrades). Test whether this interferes with nginx processing X-Accel-Redirect on a normal 200. A common fix is to gate them behind a map $http_upgrade { … } so non‑websocket requests get Connection: "".
  • The if (-f .../maintenance.on) { return 503; } inside location / (the "if is evil" rewrite‑phase quirk) — unlikely (the if is false), but worth ruling out.
  • Whether the X‑Accel internal redirect URI is being normalized/altered so it falls through (ruled out by elimination here, but re‑verify).

How to reproduce / debug

  1. Set config :vutuv, :post_image_serving, :accel_redirect in config/runtime.exs, deploy (cold), upload an image — it renders broken.
  2. Controlled X‑Accel test that needs no auth/PII: a throwaway backend on a localhost port that answers any GET with 200 + X-Accel-Redirect: /internal_post_images/<token>/thumb.avif, plus a temporary location proxying to it, then curl it. 200 + image bytes ⇒ X‑Accel works (issue is elsewhere); bare internal 404 ⇒ confirms the nginx‑level rejection. Revert after.

Note

If/when re‑enabled, the internal_post_images location must exist in every app‑serving vhost. Right now only vutuv.de has it; new.vutuv.de is a return 503 stub (stopped 2026‑06‑01) and would also need it when revived. send_file (current) needs no nginx location and serves both with zero per‑vhost config — which is why it's the safe default.

See README → Deployment → "Post image serving (no nginx setup needed)".

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions