#Next.js Image Optimization returns original JPEG in production (self-hosted, standalone, S3)

1 messages Β· Page 1 of 1 (latest)

elfin shale
#

Hi everyone πŸ‘‹

I’m running a self-hosted Next.js app (v15.5.13) with output: "standalone" on Docker (Dokploy + Traefik), and I’m having an issue where Image Optimization does not work in production, even though it works locally.


⚠️ Problem

In production, requests to /_next/image return:

  • content-type: image/jpeg
  • content-length: ~6.7MB (same as original)
  • even on x-nextjs-cache: MISS

Expected behavior:

  • should return optimized image (WebP ~130KB)

βš™οΈ Setup

  • Next.js (15.5.13)
  • Deployment: Dokploy (Docker + Traefik)
  • Image source: AWS S3
  • Node: 24
  • sharp (0.33.5)

I have downgraded sharp, because it was not working:

  • native sharp failed because the linux-x64 binary requires x64-v2 / SSE4.2-level CPU features
  • wasm sharp failed because Wasm SIMD is unsupported in that environment

next.config.ts:

images: {
    // Allowed remote images
    qualities: [1, 50, 60, 75],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [32, 48, 64, 96, 128, 256, 384],
    minimumCacheTTL: 60 * 60 * 24 * 7, // 7 days
    formats: ["image/webp"],
    remotePatterns: [
      {
        protocol: "https",
        hostname: "HERE_IS_MY_AWS_S3_URL",
        port: "",
        pathname: "/**",
        search: ""
      }
    ]
  }

Docker (Simplified):

FROM node:24 AS base
...
ENV NEXT_SHARP_PATH=/app/node_modules/sharp

❓ Question

Why would Next.js image optimizer fail

Is this:

  • a limitation of standalone mode?
  • related to Node 24?
  • a known issue with remote images?
  • or a silent fallback in the optimizer?

Any help appreciated! πŸ™

At this point I’m considering replacing /_next/image with a custom sharp API or pre-generated variants, but I’d really like to understand why the built-in optimizer fails here.

Thanks!

devout ospreyBOT
#

πŸ”Ž This post has been indexed in our web forum and will be seen by search engines so other users can find it outside Discord

πŸ•΅οΈ Your user profile is private by default and won't be visible to users outside Discord, if you want to be visible in the web forum you can add the "Public Profile" role in id:customize

βœ… You can mark a message as the answer for your post with Right click -> Apps -> Mark Solution
(if you don't see the option, try refreshing Discord with Ctrl + R)

elfin shale
#

βœ… What works

Locally (same code, same S3 image):

  • content-type: image/webp
  • content-length: ~135KB

Manual test inside the container:

const sharp = require('sharp');
const res = await fetch('IMAGE_URL');
const buf = Buffer.from(await res.arrayBuffer());

const out = await sharp(buf)
  .resize({ width: 1920 })
  .webp({ quality: 60 })
  .toBuffer();

console.log(out.length); // ~135KB

So:

  • sharp works
  • image is valid
  • conversion to WebP works

❌ What does NOT work

Production request:
/_next/image?url=...&w=1920&q=60

Response:
content-type: image/jpeg
content-length: 6714342
x-nextjs-cache: MISS

Second response:
content-type: image/jpeg
content-length: 6714342
x-nextjs-cache: HIT

Even after:
clearing .next/cache/images
rebuilding container
hard reload


fresh berry
# elfin shale Hi everyone πŸ‘‹ I’m running a self-hosted Next.js app (v15.5.13) with `output: "...

Diagnosing the /_next/image Issue πŸ”
Based on your setup, this is almost certainly a silent sharp fallback β€” Next.js can’t use sharp, so it falls back to passing the original image through unoptimized. Here’s the full breakdown:

Root Cause: No working sharp = no optimization
Next.js image optimization requires sharp at runtime in standalone mode. When sharp fails to load (for any reason), Next.js does not throw an error β€” it silently serves the original image as-is. That’s exactly what you’re seeing: same file size, correct content-type, but no actual optimization.
You’ve already identified both sharp failure modes:

Mode Your Error
sharp (native) Requires SSE4.2 / x64-v2 CPU β€” your host CPU is older
@img/sharp-wasm32 Wasm SIMD unsupported in your Node/V8 environment

Why NEXT_SHARP_PATH Isn’t Enough
Setting NEXT_SHARP_PATH tells Next.js where to find sharp, but if the binary at that path can’t actually execute (CPU mismatch, Wasm SIMD missing), the import fails silently and optimization is skipped. The env var doesn’t fix the underlying incompatibility.

Fix Options (in order of preference)
Option 1: Force a compatible sharp build (recommended)
The native SSE4.2 requirement comes from the prebuilt binary. You can force a build from source inside your Docker image, which compiles for your specific CPU:

FROM node:24 AS base

Install build deps for compiling sharp from source

RUN apt-get update && apt-get install -y
python3 make g++ libvips-dev
&& rm -rf /var/lib/apt/lists/*

Force sharp to compile from source instead of using prebuilt

ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
ENV npm_config_sharp_binary_host=""

After your npm/pnpm install:

RUN npm rebuild sharp --build-from-source

or: pnpm rebuild sharp --build-from-source

This compiles sharp against the actual CPU in your Docker host rather than relying on a prebuilt binary that assumes SSE4.2.

Option 2: Pin to a sharp version with a broader prebuilt
sharp 0.32.x prebuilts had slightly broader CPU compatibility. Worth trying before going full compile-from-source:

npm install sharp@0.32.6

Option 3: Use a Wasm build with SIMD disabled
If Wasm SIMD is the blocker, you can try forcing the non-SIMD Wasm path:

ENV NEXT_SHARP_PATH=/app/node_modules/@img/sharp-wasm32

And install it explicitly:

npm install @img/sharp-wasm32

Note: this is significantly slower than native but will optimize images correctly.

Option 4: Verify sharp actually loads at startup
Add this to your next.config.ts temporarily to confirm whether sharp is loading:

// At the top of next.config.ts
try {
const sharp = require('sharp');
console.log('βœ… sharp loaded:', sharp.versions);
} catch (e) {
console.error('❌ sharp failed to load:', e.message);
}

Check your Docker container logs on startup β€” if you see the ❌, that confirms the silent fallback.

Option 5: Custom /api/image route (your fallback plan)
If none of the above work due to infrastructure constraints, a custom route using sharp directly gives you full control:

// app/api/image/route.ts
import sharp from 'sharp';

export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const src = searchParams.get('src')!;
const w = Number(searchParams.get('w') ?? 1200);
const q = Number(searchParams.get('q') ?? 75);

const res = await fetch(src);
const buffer = Buffer.from(await res.arrayBuffer());

const optimized = await sharp(buffer)
.resize(w)
.webp({ quality: q })
.toBuffer();

return new Response(optimized, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=604800, immutable',
},
});
}

#

Quick Diagnosis Checklist βœ…

[ ] Check container logs on startup for sharp load errors
[ ] Run: docker exec <container> node -e "require('sharp')"
[ ] Check CPU: docker exec <container> grep flags /proc/cpuinfo | grep sse4_2
[ ] Confirm Node version inside container: docker exec <container> node --version

The SSE4.2 check is the key one β€” if sse4_2 doesn’t appear in /proc/cpuinfo flags, native sharp will never work on that host without compiling from source. πŸŽ―β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹

fresh berry
# elfin shale βœ… What works Locally (same code, same S3 image): - `content-type: image/webp` -...

Interesting β€” Sharp Works, But Next.js Doesn’t Use It πŸ€”
This changes everything. Sharp is fine. The problem is upstream of sharp β€” Next.js is either not reaching the optimization step, or something is intercepting/caching the response before Next.js can process it.

Most Likely Culprit: Traefik is Caching/Proxying the Original
The smoking gun is this:

x-nextjs-cache: HIT ← on second request
content-type: image/jpeg
content-length: 6714342 ← identical size, every time

If Next.js were serving this, a HIT would mean it cached the optimized WebP. But you’re getting the original JPEG cached as a HIT β€” which means something between the client and Next.js is:
1. Fetching the raw S3 image directly
2. Caching and returning it, bypassing /_next/image processing entirely
Check your Traefik config for any rules that match /_next/image or proxy *.s3.amazonaws.com URLs directly. Also check for a CDN layer (CloudFront, etc.) in front of your domain.

Other High-Probability Causes

  1. The url= param is being rewritten
    Traefik middleware (stripPrefix, rewrite, redirect) could be transforming the /_next/image?url=... request so Next.js receives a mangled URL β€” causing it to fetch and passthrough the raw image instead of failing loudly.
    Add a debug log to confirm what Next.js actually receives:

// middleware.ts (project root)
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
if (req.nextUrl.pathname === '/_next/image') {
console.log('[img-debug]', {
url: req.nextUrl.searchParams.get('url'),
w: req.nextUrl.searchParams.get('w'),
q: req.nextUrl.searchParams.get('q'),
});
}
return NextResponse.next();
}

  1. formats: ["image/webp"] requires the client to send Accept: image/webp
    Next.js image optimization is content-negotiated β€” it only returns WebP if the request includes Accept: image/webp. If Traefik strips or rewrites the Accept header, Next.js falls back to the original format (JPEG).
    Test this directly, bypassing Traefik entirely:

Hit the container directly (no Traefik)

curl -v
-H "Accept: image/webp,image/jpeg,/"
"http://localhost:3000/_next/image?url=YOUR_ENCODED_S3_URL&w=1920&q=60"

If this returns WebP but the production URL returns JPEG, Traefik is stripping Accept.

  1. Standalone output missing sharp in the right place
    Even though node -e "require('sharp')" works, standalone mode copies a subset of node_modules. Confirm sharp is actually present in the standalone output:

ls .next/standalone/node_modules | grep sharp
ls .next/standalone/node_modules/sharp/build/Release/

If sharp.node is missing from the standalone copy, Next.js silently falls back. The NEXT_SHARP_PATH env var should handle this, but verify it points to the right binary inside the container:

docker exec <container> ls $NEXT_SHARP_PATH/build/Release/

Definitive Test Order πŸ§ͺ

1. Bypass Traefik entirely β€” hit Next.js container directly

curl -H "Accept: image/webp,/"
"http://<container-ip>:3000/_next/image?url=ENCODED_URL&w=1920&q=60"
-o test.webp -v

2. Check what content-type comes back

If WebP β†’ Traefik is the problem

If still JPEG β†’ problem is inside Next.js/container

3. Confirm sharp path inside container

docker exec <container> node -e "
const p = process.env.NEXT_SHARP_PATH || 'sharp';
const s = require(p);
console.log('sharp ok, version:', s.versions?.sharp);
"

The direct container test (#1) will tell you definitively whether this is a Traefik problem or a Next.js/sharp problem. πŸŽ―β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹β€‹

elfin shale
#

Thank you for the quick response.

I have managed to bypass traefik and hit Next.js container directly, but it seams the problem is inside Next.js/container.

Here is the tests:
Hitting Next.js container directly:

root@20712429a626:/# curl -v \
  -H 'Accept: image/webp,image/*,*/*' \
  'http://vexen-dev-web-1jvagm:3000/_next/image?url=...&w=1920&q=60'

Response:

  • Content-Type: image/jpeg
  • Content-Length: 6714342
  • X-Nextjs-Cache: MISS β†’ then HIT

So even without Traefik, it returns the original JPEG and caches it.

Confirming sharp path inside container:

root@20712429a626:/#  node -e "
  const p = process.env.NEXT_SHARP_PATH || 'sharp';
  const s = require(p);
  console.log('sharp ok, version:', s.versions?.sharp);
"
sharp ok, version: 0.33.5

Additionaly, I have added this to next.config.ts:

try {
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const sharp = require(process.env.NEXT_SHARP_PATH || "sharp")
  console.log(":white_check_mark: SHARP OK:", sharp.versions)
} catch (e) {
  console.error(":x: SHARP FAIL:", e)
}

And inside GitHub actions where I have build the image, I got possitive result:

#14 1.157 :white_check_mark: SHARP OK: {
...
#14 1.157   sharp: '0.33.5'
#14 1.157 }
#

Moreover, I build simple page to test different type of images (local, remote, remote like in the live app, and with custom loader)

...
<Image src="/couch_lights_on.jpg" alt="Just Image" width={800} height={600} quality={75} />
...
<Image src="https://dev-vexen.s3.eu-central-1.amazonaws.com/f2a9056a5601a9367a749be3e4e50590aa2cbaa3.jpg" alt="Just Image" width={800} height={600} quality={75} />
...
<Image src={getUrlByName("f2a9056a5601a9367a749be3e4e50590aa2cbaa3.jpg")} alt="Hero Banner" fill sizes="900px" quality={60} loading="eager" fetchPriority="high" className="object-cover -z-20 md:rounded-[12px]" />
...
<Image src={`/api/image?url=${encodeURIComponent("https://dev-vexen.s3.eu-central-1.amazonaws.com/f2a9056a5601a9367a749be3e4e50590aa2cbaa3.jpg")}&w=900&q=60`} alt="Hero Banner" width={900} height={609} unoptimized />
...

Custom route:

import sharp from "sharp"

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)
  const url = searchParams.get("url")
  const w = Number(searchParams.get("w") || "1080")
  const q = Number(searchParams.get("q") || "75")

  if (!url) {
    return new Response("Missing url", { status: 400 })
  }

  const upstream = await fetch(url, {
    cache: "force-cache"
  })

  if (!upstream.ok) {
    return new Response("Failed to fetch source image", { status: 502 })
  }

  const input = Buffer.from(await upstream.arrayBuffer())

  const output = await sharp(input).resize({ width: w, withoutEnlargement: true }).webp({ quality: q }).toBuffer()

  return new Response(new Uint8Array(output), {
    status: 200,
    headers: {
      "Content-Type": "image/webp",
      "Cache-Control": "public, max-age=31536000, immutable"
    }
  })
}

Now I see in the logs error:

Failed to set Next.js data cache for https://.../f2a9056a5601a9367a749be3e4e50590aa2cbaa3.jpg, items over 2MB can not be cached (8953121 bytes)

The Static image and Remote image with custome loader were optimized.

fresh berry
fresh berry
elfin shale
elfin shale
#

I confirmed the /_next/image request inside Next middleware. It receives:

accept: image/avif,image/webp,...
correct url
correct w / q

So Accept forwarding is not the issue.

Also, while handling /_next/image, the container logs show:

WARNING: CPU supports 0x6000000000004000, software requires 0x4000000000005000

At the same time:

manual sharp(...).webp() inside the same container works
/_next/image still returns JPEG

So it looks like Next’s image optimizer path is still hitting a CPU-incompatible binary/runtime path and silently falling back, even though manual sharp conversion works.

Debug route:

// app/api/debug-accept/route.ts:
import { headers } from "next/headers"

export async function GET() {
  const h = await headers()

  return Response.json({
    accept: h.get("accept"),
    host: h.get("host"),
    xForwardedProto: h.get("x-forwarded-proto")
  })
}

Result:

{"accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","host":"dev.vexen.eu","xForwardedProto":"https"}

middleware.ts:

...
  if (nextUrl.pathname === "/_next/image") {
    console.log("[_next/image]", {
      accept: req.headers.get("accept"),
      url: nextUrl.searchParams.get("url"),
      w: nextUrl.searchParams.get("w"),
      q: nextUrl.searchParams.get("q"),
      host: req.headers.get("host"),
      xForwardedProto: req.headers.get("x-forwarded-proto")
    })
    return null
  }
...

result:

[_next/image] {
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
url: 'https://dev-vexen.s3.eu-central-1.amazonaws.com/f2a9056a5601a9367a749be3e4e50590aa2cbaa3.jpg',
w: '1920',
q: '75',
host: 'dev.vexen.eu',
xForwardedProto: 'https'
}

At this point, does this look like a known Next.js image optimizer issue with older CPUs / standalone mode, or is there another runtime path I should inspect?

fresh berry
#

Put this inside your Docker file

elfin shale
#

Hi, I tried the suggested Docker fix, but the build fails during CD.

#20 [runner  7/11] RUN cp -r /app/node_modules/sharp     /app/.next/standalone/node_modules/sharp
#20 0.153 cp: cannot create directory '/app/.next/standalone/node_modules/sharp': No such file or directory
#20 ERROR: process "/bin/sh -c cp -r /app/node_modules/sharp     /app/.next/standalone/node_modules/sharp" did not complete successfully: exit code: 1
------
 > [runner  7/11] RUN cp -r /app/node_modules/sharp     /app/.next/standalone/node_modules/sharp:
0.153 cp: cannot create directory '/app/.next/standalone/node_modules/sharp': No such file or directory
------
Dockerfile:41
--------------------
  40 |     
  41 | >>> RUN cp -r /app/node_modules/sharp \
  42 | >>>     /app/.next/standalone/node_modules/sharp
  43 |     
--------------------
ERROR: failed to build: failed to solve: process "/bin/sh -c cp -r /app/node_modules/sharp     /app/.next/standalone/node_modules/sharp" did not complete successfully: exit code: 1

So in the runner stage, /app/.next/standalone/node_modules does not exist.

Because I copy .next/standalone into /app with:

COPY --from=builder /app/.next/standalone ./

I think there is no /app/.next/standalone/... path in the final image.

Should this copy step be done in the builder stage instead, before COPY --from=builder /app/.next/standalone ./? Or should I be copying sharp into a different path in the runner stage?

#
# FROM node:20-alpine AS base
FROM node:24 AS base
LABEL org.opencontainers.image.source=https://github.com/AndreyPerunov/vexen

RUN apt-get update -y \
 && apt-get install -y openssl \
 && rm -rf /var/lib/apt/lists/*

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: Build the application
FROM base AS builder
WORKDIR /app

ARG NEXT_PUBLIC_IMAGES_PATH
ARG NEXT_PUBLIC_GTM_ID
ENV NEXT_PUBLIC_GTM_ID=$NEXT_PUBLIC_GTM_ID
ENV NEXT_PUBLIC_IMAGES_PATH=$NEXT_PUBLIC_IMAGES_PATH

COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build

RUN mkdir -p /app/.next/standalone/node_modules
RUN rm -rf /app/.next/standalone/node_modules/sharp
RUN cp -r /app/node_modules/sharp /app/.next/standalone/node_modules/sharp

# Stage 3: Production server
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_SHARP_PATH=/app/node_modules/sharp

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules

# Create logs directory
RUN mkdir -p /app/logs
RUN mkdir -p /app/logs/users
RUN touch /app/logs/web.log
RUN touch /app/logs/users/user.log

EXPOSE 3000
CMD ["sh","-c","npx prisma migrate deploy && exec node server.js 2>&1 | tee -a /app/logs/web.log"]
fresh berry
elfin shale
#

I updated my docker file but, there is still no image optimization for remote images, and local images are still in the jpeg format.

Could it be becouse of this warning?
WARNING: CPU supports 0x6000000000004000, software requires 0x4000000000005000

# FROM node:20-alpine AS base
FROM node:24 AS base
LABEL org.opencontainers.image.source=https://github.com/AndreyPerunov/vexen

RUN apt-get update -y \
 && apt-get install -y openssl \
 && rm -rf /var/lib/apt/lists/*

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: Build the application
FROM base AS builder
WORKDIR /app

ARG NEXT_PUBLIC_IMAGES_PATH
ARG NEXT_PUBLIC_GTM_ID
ENV NEXT_PUBLIC_GTM_ID=$NEXT_PUBLIC_GTM_ID
ENV NEXT_PUBLIC_IMAGES_PATH=$NEXT_PUBLIC_IMAGES_PATH

COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build

RUN cp -r /app/node_modules/sharp /app/.next/standalone/node_modules/sharp

# Stage 3: Production server
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_SHARP_PATH=/app/node_modules/sharp

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules

# Create logs directory
RUN mkdir -p /app/logs
RUN mkdir -p /app/logs/users
RUN touch /app/logs/web.log
RUN touch /app/logs/users/user.log

EXPOSE 3000
CMD ["sh","-c","npx prisma migrate deploy && exec node server.js 2>&1 | tee -a /app/logs/web.log"]
elfin shale
#

I fixed an issue. Now everything works. The problem was indeed in WARNING: CPU supports 0x6000000000004000, software requires 0x4000000000005000 warning. (Because there was no ssse3 flag)

I asked my system administrator to change CPU type to host. So after full reboot, image optimization worked.

Before:
CPU: Common KVM processor

After:
Model name: Intel(R) Xeon(R) CPU E5-2680 ....

Here is where I found the solution:
https://github.com/lovell/sharp/issues/3893#issuecomment-1976067365

GitHub

Possible install-time or require-time problem I have read the documentation relating to installation. Are you using the latest version of sharp? I am using the latest version of sharp as reported b...

#

Thank for the help!

fresh berry
#

also i wouldnt say claude is useless

#

claude is decent with coding

#

especially if it has right instructions and docs

fallow fable