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
- Set
config :vutuv, :post_image_serving, :accel_redirect in config/runtime.exs, deploy (cold), upload an image — it renders broken.
- 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)".
TL;DR Production serves post images with
send_file(the app streams the bytes) because the more efficientX-Accel-Redirecthandoff to nginx silently fails on prod: nginx returns its bareinternal404 instead of streaming the file, even though theinternal_post_imageslocation 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_fileworks 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 inserve/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 returns200with an empty body and a headerX-Accel-Redirect: /internal_post_images/<token>/<version>.avif; nginx is expected to stream the file from aninternallocation. More efficient (nginx pushes the bytes, not the BEAM), but currently broken on prod — see below.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:/etc/nginx/sites-available/vutuv3, server blockvutuv.de) does contain the location, it is correct, and the site is enabled:.aviffiles exist on disk (/srv/vutuv3/post_images/<token>/) and are readable bywww-data(the nginx user); the full path traverses; no AppArmor profile on nginx;/srv/vutuv3is a real dir (nodisable_symlinks); noproxy_ignore_headers.x-accel-redirect: /internal_post_images/<token>/<version>.avif— confirmed bytest/vutuv_web/controllers/post_image_controller_test.exs("production serving mode") and by the deployed BEAM./internal_post_images/<token>/thumb.avifreturns nginx's bareinternal404 — i.e. the regex matches andinternalrejection works as designed.internal404 and never logged anopen()attempt for the file (zeroopen() failedlines invutuv.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)
NoRouteError404internal404The browser logged
404 141→ the nginx bareinternal404, confirming the app behaved correctly and the failure is in nginx's handling of the X‑Accel redirect.Hypotheses to test (none proven)
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 processingX-Accel-Redirecton a normal200. A common fix is to gate them behind amap $http_upgrade { … }so non‑websocket requests getConnection: "".if (-f .../maintenance.on) { return 503; }insidelocation /(the "if is evil" rewrite‑phase quirk) — unlikely (theifis false), but worth ruling out.How to reproduce / debug
config :vutuv, :post_image_serving, :accel_redirectinconfig/runtime.exs, deploy (cold), upload an image — it renders broken.200+X-Accel-Redirect: /internal_post_images/<token>/thumb.avif, plus a temporarylocationproxying to it, thencurlit.200+ image bytes ⇒ X‑Accel works (issue is elsewhere); bareinternal404 ⇒ confirms the nginx‑level rejection. Revert after.Note
If/when re‑enabled, the
internal_post_imageslocation must exist in every app‑serving vhost. Right now onlyvutuv.dehas it;new.vutuv.deis areturn 503stub (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)".