Serving Files
Uploaded files are only useful when browsers can fetch them. This chapter shows how to expose files and thumbnails, first through your web framework and then, for production, through a reverse proxy that serves the bulk of the traffic without touching your application. It ends with cache cleanup and the environment variables you can set.
Both createFileServer and createImageServer return the same interface:
(pathname: string) => Promise<Response | null>
Response is the Web Standard API — available natively in Node 20+, Bun, Deno, and edge runtimes. null means the request does not match the configured prefix — pass it on to your framework.
URL schemas
Raw file: GET /{servePrefix}/{groupId}-{version}/{filename}
Thumbnail: GET /{thumbPrefix}/{groupId}-{version}/{mode-segment}/{filename}.webp
Default prefixes: /file and /img.
Framework integrations
SvelteKit
import type { Handle } from '@sveltejs/kit';
import { createFileServer } from '@atom-forge/attachment';
import { createImageServer } from '@atom-forge/attachment';
const fileServer = createFileServer({ provider, servePrefix: '/file' });
const imageServer = createImageServer({ variants, sourceProvider, thumbProvider, focusSecret });
export const handle: Handle = async ({ event, resolve }) =>
(await fileServer(event.url.pathname))
?? (await imageServer(event.url.pathname))
?? resolve(event);
Hono
import { Hono } from 'hono';
const app = new Hono();
app.use('/file/*', async (c, next) =>
(await fileServer(new URL(c.req.url).pathname)) ?? next()
);
app.use('/img/*', async (c, next) =>
(await imageServer(new URL(c.req.url).pathname)) ?? next()
);
Express
Express does not support Web Response natively — use a small adapter:
import express from 'express';
async function sendWebResponse(response: Response, res: express.Response) {
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
res.end(Buffer.from(await response.arrayBuffer()));
}
app.use('/file', async (req, res, next) => {
const r = await fileServer(req.path);
if (!r) return next();
await sendWebResponse(r, res);
});
Next.js (App Router)
Option A — middleware.ts:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
return (await fileServer(pathname)) ?? (await imageServer(pathname)) ?? NextResponse.next();
}
export const config = { matcher: ['/file/:path*', '/img/:path*'] };
Option B — route handlers (app/file/[...slug]/route.ts):
export async function GET(request: Request) {
const pathname = new URL(request.url).pathname;
return (await fileServer(pathname)) ?? new Response('Not found', { status: 404 });
}
Nuxt 3 / Nitro
export default defineEventHandler(async (event) => {
const r = (await fileServer(event.path)) ?? (await imageServer(event.path));
if (!r) return;
setResponseStatus(event, r.status);
for (const [key, value] of r.headers) setResponseHeader(event, key, value);
return send(event, Buffer.from(await r.arrayBuffer()));
});
In production, a reverse proxy usually sits in front of your application. The diagram shows who answers what: files and already generated thumbnails are served straight from disk or S3, and only a thumbnail that does not exist yet is generated by the app server.
flowchart LR
B[Browser] --> X[Reverse proxy]
X -- file / cached thumb exists --> D[(Disk or S3)]
X -- thumb cache miss --> A[App server]
A --> G[Generate WebP thumbnail]
G --> D
A --> X
Proxy configuration
A reverse proxy in front of the app server lets you serve raw files and cached thumbnails directly — the app server only receives thumbnail cache-miss requests.
Path derivation
/file → local filesystem:
/file/abc123-3/photo.jpg
└──────┘ groupId = abc123, shard = ab (first 2 chars)
→ /var/uploads/ab/abc123/photo.jpg
/file → S3/MinIO (shard stripped — object storage needs no directory sharding):
/file/abc123-3/photo.jpg → abc123/photo.jpg
/img → flat cache key:
/img/abc123-3/e.400x400/photo.jpg.webp
→ abc123-3.e.400x400.photo.jpg.webp (slashes → dots)
Local provider — /file
Nginx
location ~ "^/file/(?<shard>[a-z0-9]{2})(?<gidrest>[a-z0-9]*)-[a-z0-9]+/(?<filename>.+)$" {
alias /var/uploads/$shard/$shard$gidrest/$filename;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Apache
AliasMatch "^/file/([a-z0-9]{2})([a-z0-9]*)-[a-z0-9]+/(.+)$" "/var/uploads/$1/$1$2/$3"
<Directory "/var/uploads">
Options -Indexes
Require all granted
Header always set Cache-Control "public, max-age=31536000, immutable"
</Directory>
Caddy
@rawfile path_regexp rawfile "^/file/([a-z0-9]{2})([a-z0-9]*)-[a-z0-9]+/(.+)$"
handle @rawfile {
root * /var/uploads
rewrite * /{http.regexp.rawfile.1}/{http.regexp.rawfile.1}{http.regexp.rawfile.2}/{http.regexp.rawfile.3}
file_server
header Cache-Control "public, max-age=31536000, immutable"
}
Local provider — /img
Cache misses are proxied to the app server, which generates and writes the thumbnail, then returns it.
Nginx
location ~ "^/img/([^/]+)/([^/]+)/(.+)$" {
root /var/thumb;
try_files /$1.$2.$3 @generate;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location @generate {
proxy_pass http://localhost:3000;
}
Apache
RewriteEngine On
RewriteRule "^/img/([^/]+)/([^/]+)/(.+)$" - [E=FLATKEY:$1.$2.$3,C]
RewriteCond "/var/thumb/%{ENV:FLATKEY}" -f
RewriteRule "^/img/([^/]+)/([^/]+)/(.+)$" "/thumbcache/$1.$2.$3" [PT,L]
RewriteRule "^/img/" "http://localhost:3000%{REQUEST_URI}" [P,L]
Alias "/thumbcache" "/var/thumb"
<Directory "/var/thumb">
Options -Indexes
Require all granted
Header always set Cache-Control "public, max-age=31536000, immutable"
</Directory>
Caddy
@thumbOnDisk {
path_regexp thumb "^/img/([^/]+)/([^/]+)/(.+)$"
file { root /var/thumb; try_files /{http.regexp.thumb.1}.{http.regexp.thumb.2}.{http.regexp.thumb.3} }
}
handle @thumbOnDisk {
root * /var/thumb
rewrite * /{http.regexp.thumb.1}.{http.regexp.thumb.2}.{http.regexp.thumb.3}
file_server
header Cache-Control "public, max-age=31536000, immutable"
}
handle /img/* { reverse_proxy localhost:3000 }
S3 / MinIO — /file with a files/ prefix
The examples below assume sourceProvider uses prefix: 'files/'. Omit files/
from each rewritten target when no prefix is configured.
Nginx
# AWS S3
location ~ "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$" {
rewrite "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$" /files/$1/$2 break;
proxy_pass https://my-bucket.s3.eu-central-1.amazonaws.com;
proxy_ssl_server_name on;
proxy_set_header Host my-bucket.s3.eu-central-1.amazonaws.com;
}
# MinIO
location ~ "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$" {
rewrite "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$" /my-bucket/files/$1/$2 break;
proxy_pass http://minio:9000;
}
Apache
RewriteEngine On
# AWS S3
RewriteRule "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$" \
"https://my-bucket.s3.eu-central-1.amazonaws.com/files/$1/$2" [P,L]
# MinIO
RewriteRule "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$" \
"http://minio:9000/my-bucket/files/$1/$2" [P,L]
Caddy
@rawfile path_regexp rawfile "^/file/([a-z0-9]+)-[a-z0-9]+/(.+)$"
# AWS S3
handle @rawfile {
rewrite * /files/{http.regexp.rawfile.1}/{http.regexp.rawfile.2}
reverse_proxy https://my-bucket.s3.eu-central-1.amazonaws.com {
header_up Host my-bucket.s3.eu-central-1.amazonaws.com
}
}
S3 / MinIO — /img with an images/ prefix
The example below assumes thumbProvider uses prefix: 'images/'. Replace
my-bucket/images with the thumbnail bucket name when using separate buckets.
Nginx
location ~ "^/img/([^/]+)/([^/]+)/(.+)$" {
rewrite "^/img/([^/]+)/([^/]+)/(.+)$" /my-bucket/images/$1.$2.$3 break;
proxy_pass http://minio:9000;
proxy_intercept_errors on;
error_page 404 = @generate;
}
location @generate {
proxy_pass http://localhost:3000$request_uri;
}
Apache
ProxyPass "/img/" "http://localhost:3000/img/"
ProxyPassReverse "/img/" "http://localhost:3000/img/"
Caddy
@thumb path_regexp thumb "^/img/([^/]+)/([^/]+)/(.+)$"
route @thumb {
rewrite * /my-thumbs/{http.regexp.thumb.1}.{http.regexp.thumb.2}.{http.regexp.thumb.3}
reverse_proxy http://minio:9000 {
@miss status 404
handle_response @miss {
rewrite * {http.request.orig_uri.path}
reverse_proxy http://localhost:3000
}
}
}
Thumbnail cache cleanup
When a file is replaced, its version changes and old thumbnails become orphaned.
Local provider
find /var/thumb -type f -atime +30 -delete
Add to crontab:
0 3 * * * find /var/thumb -type f -atime +30 -delete
S3 / MinIO — lifecycle rule
aws s3api put-bucket-lifecycle-configuration \
--bucket my-thumbs \
--lifecycle-configuration '{
"Rules": [{ "ID": "expire-thumbs", "Status": "Enabled", "Filter": {}, "Expiration": { "Days": 30 } }]
}'
mc ilm add --expiry-days 30 myminio/my-thumbs
Environment variables
| Variable |
Default |
Description |
UPLOAD_DIR |
./var/uploads |
Root directory for uploaded files |
SERVE_PREFIX |
/file |
URL prefix for raw file serving |
THUMB_DIR |
./var/thumb |
Flat cache directory for generated thumbnails |
THUMB_PREFIX |
/img |
URL prefix for thumbnail serving |
FOCUS_SECRET |
(required) |
HMAC secret for manual-focus hash validation |
Serving Files
Uploaded files are only useful when browsers can fetch them. This chapter shows how to expose files and thumbnails, first through your web framework and then, for production, through a reverse proxy that serves the bulk of the traffic without touching your application. It ends with cache cleanup and the environment variables you can set.
Both
createFileServerandcreateImageServerreturn the same interface:(pathname: string) => Promise<Response | null>Responseis the Web Standard API — available natively in Node 20+, Bun, Deno, and edge runtimes.nullmeans the request does not match the configured prefix — pass it on to your framework.URL schemas
Default prefixes:
/fileand/img.Framework integrations
SvelteKit
// src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; import { createFileServer } from '@atom-forge/attachment'; import { createImageServer } from '@atom-forge/attachment'; const fileServer = createFileServer({ provider, servePrefix: '/file' }); const imageServer = createImageServer({ variants, sourceProvider, thumbProvider, focusSecret }); export const handle: Handle = async ({ event, resolve }) => (await fileServer(event.url.pathname)) ?? (await imageServer(event.url.pathname)) ?? resolve(event);Hono
import { Hono } from 'hono'; const app = new Hono(); app.use('/file/*', async (c, next) => (await fileServer(new URL(c.req.url).pathname)) ?? next() ); app.use('/img/*', async (c, next) => (await imageServer(new URL(c.req.url).pathname)) ?? next() );Express
Express does not support Web
Responsenatively — use a small adapter:import express from 'express'; async function sendWebResponse(response: Response, res: express.Response) { res.status(response.status); response.headers.forEach((value, key) => res.setHeader(key, value)); res.end(Buffer.from(await response.arrayBuffer())); } app.use('/file', async (req, res, next) => { const r = await fileServer(req.path); if (!r) return next(); await sendWebResponse(r, res); });Next.js (App Router)
Option A —
middleware.ts:// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; return (await fileServer(pathname)) ?? (await imageServer(pathname)) ?? NextResponse.next(); } export const config = { matcher: ['/file/:path*', '/img/:path*'] };Option B — route handlers (
app/file/[...slug]/route.ts):export async function GET(request: Request) { const pathname = new URL(request.url).pathname; return (await fileServer(pathname)) ?? new Response('Not found', { status: 404 }); }Nuxt 3 / Nitro
// server/middleware/attachments.ts export default defineEventHandler(async (event) => { const r = (await fileServer(event.path)) ?? (await imageServer(event.path)); if (!r) return; setResponseStatus(event, r.status); for (const [key, value] of r.headers) setResponseHeader(event, key, value); return send(event, Buffer.from(await r.arrayBuffer())); // Or: return sendWebResponse(event, r) — Nitro 2.6+ });In production, a reverse proxy usually sits in front of your application. The diagram shows who answers what: files and already generated thumbnails are served straight from disk or S3, and only a thumbnail that does not exist yet is generated by the app server.
Proxy configuration
A reverse proxy in front of the app server lets you serve raw files and cached thumbnails directly — the app server only receives thumbnail cache-miss requests.
Path derivation
/file→ local filesystem:/file→ S3/MinIO (shard stripped — object storage needs no directory sharding):/img→ flat cache key:Local provider —
/fileNginx
Apache
Caddy
Local provider —
/imgCache misses are proxied to the app server, which generates and writes the thumbnail, then returns it.
Nginx
Apache
Caddy
S3 / MinIO —
/filewith afiles/prefixThe examples below assume
sourceProviderusesprefix: 'files/'. Omitfiles/from each rewritten target when no prefix is configured.Nginx
Apache
Caddy
S3 / MinIO —
/imgwith animages/prefixThe example below assumes
thumbProviderusesprefix: 'images/'. Replacemy-bucket/imageswith the thumbnail bucket name when using separate buckets.Nginx
Apache
Caddy
Thumbnail cache cleanup
When a file is replaced, its version changes and old thumbnails become orphaned.
Local provider
# Delete thumbnails not accessed in 30 days find /var/thumb -type f -atime +30 -deleteAdd to crontab:
S3 / MinIO — lifecycle rule
# AWS S3 aws s3api put-bucket-lifecycle-configuration \ --bucket my-thumbs \ --lifecycle-configuration '{ "Rules": [{ "ID": "expire-thumbs", "Status": "Enabled", "Filter": {}, "Expiration": { "Days": 30 } }] }' # MinIO mc ilm add --expiry-days 30 myminio/my-thumbsEnvironment variables
UPLOAD_DIR./var/uploadsSERVE_PREFIX/fileTHUMB_DIR./var/thumbTHUMB_PREFIX/imgFOCUS_SECRET