Self-Hosted Next.js Image Optimization on a VPS

Self-Hosted Next.js Image Optimization on a VPS

Tako-kun ·

Next.js made images feel like a component. Drop in <Image>, give it a source, and let the platform worry about width variants, modern formats, cache headers, and remote source rules.

That works beautifully when the platform is Vercel. It also works self-hosted with next start, because Next optimizes images at runtime. But when you deploy a Next.js app to your own VPS with Tako, there is a better place for that work to live: the same server boundary that already owns routing, TLS, static files, logs, and zero-downtime deploys.

Tako lets you keep next/image. The handoff happens underneath it. withTako() configures Next’s custom image loader so generated image URLs point at /_tako/image, then tako-server validates the request, loads the source, resizes with libvips, caches the result, and sends WebP by default.

The next/image handoff

Next’s image component has two extension points that matter here. A custom loader receives src, width, and optional quality, then returns the URL the browser should request. A loaderFile in next.config.js applies that loader globally, so every <Image> component can use the same image service.

Tako wraps that config for you:

// next.config.ts
import { withTako } from "tako.sh/nextjs";

export default withTako({
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "cdn.example.com",
        pathname: "/uploads/**",
      },
    ],
  },
});

Under the hood, withTako() preserves your config, then applies the pieces Tako needs:

Next config fieldValue Tako setsWhy it matters
output"standalone"Gives Tako a deployable server output.
adapterPaththe tako.sh/nextjs adapterLets Next write .next/tako-entry.mjs after build.
allowedDevOriginsadds *.test and *.tako.testLets tako dev proxy requests through local HTTPS hosts.
images.loader"custom"Tells next/image not to use Next’s default optimizer URL.
images.loaderFileTako’s packaged loaderConverts image props into /_tako/image URLs.
images.deviceSizes[320, 640, 960, 1200, 1920]Aligns Next’s generated widths with Tako’s defaults.
images.imageSizes[]Keeps the generated variant set small and predictable.

That means your component still looks like ordinary Next.js:

import Image from "next/image";

export function Hero() {
  return (
    <Image
      src="/images/product-hero.jpg"
      alt="Product dashboard"
      width={1200}
      height={800}
      priority
    />
  );
}

When Next renders the page, Tako’s loader turns the image into a public optimizer URL:

/_tako/image?src=%2Fimages%2Fproduct-hero.jpg&w=1200

If the component asks for a different quality, that becomes q=.... If no format is specified, the server negotiates from the browser’s Accept header against the app’s configured format list. With the default config, the output is WebP.

Two allowlists, two jobs

Remote image rules exist in two places because they protect different boundaries.

Next’s remotePatterns setting keeps the component honest. If someone passes a remote src that does not match the configured protocol, hostname, port, path, and search constraints, Next rejects it.

Tako’s [images] config is the runtime boundary. It controls what tako-server is allowed to fetch and transform after a real browser request reaches your VPS. Local public paths are allowed by default. Remote URLs are denied until you allow them in tako.toml:

runtime = "node"
preset = "nextjs"

[images]
remote_patterns = ["https://cdn.example.com/uploads/**"]
formats = ["webp"]

For a typical self-hosted Next app, keep both sides aligned:

NeedConfigure in NextConfigure in Tako
Local images in public/nothing speciallocal paths work by default
Remote CMS imagesimages.remotePatterns[images].remote_patterns
Default responsive widthswithTako() sets themdefaults are already the same
WebP outputno component changedefault formats = ["webp"]
AVIF outputusually no component changeadd formats = ["avif", "webp"] if you want negotiation to prefer AVIF

The patterns are intentionally strict. * matches one path segment, ** matches the rest of a path, and remote hosts can use a leading wildcard such as https://*.example.com/uploads/**. Remote sources must be http or https, cannot include userinfo or fragments, cannot point back at the image optimizer, and cannot resolve to private or local network targets.

That last part matters. An image optimizer is a server-side fetcher and a CPU user. The useful version is not “let any browser request any URL at any size.” The useful version is “let the app publish a small, finite set of image variants that are safe to fetch, transform, and cache.”

What the VPS does

After a request matches your app route, Tako reserves /_tako/* for platform endpoints. Durable channels live there, storage object URLs live there, and public optimized images live at /_tako/image. The route is part of the same app serving model as your Next process.

Diagram

The request has to pass validation before source bytes are loaded. src and w are required. q and f are optional. Duplicate or unknown query params are rejected. Width, quality, and format must match the lists in [images].

Then the server loads the original. Local paths resolve from the deployed public/ directory first, then from the matched app backend. Remote sources use a guarded HTTP client with no proxy, no redirects, connection and request timeouts, and DNS checks that reject private or local addresses.

The performance path has two caches:

CacheScopePurpose
Source cachein memory, 10 seconds, 64 MiB, 256 entriesReuses the same original when a page asks for several widths.
Transform cachelocal disk under /tmp/tako-image-cacheReuses finished variants across requests.

Transform cache keys include the app name, release root, source bytes, output format, width, optional height/fit/crop, and quality. That means a new deploy or changed source file naturally produces a new cache key. The cache is best effort and local to each server. Tako prunes entries older than 30 days, then keeps the cache within a filesystem-based cap: 5% of the filesystem, clamped between 1 GiB and 4 GiB.

The actual resize and encode work runs in an isolated child process. That is not an aesthetic choice. Image codecs are native code, and native code deserves a process boundary. Tako limits concurrent transforms, queues a bounded number of misses, and times out work that does not finish. Cache hits and duplicate in-flight misses skip the worker queue entirely.

If transform work fails after a verified image source was already loaded, Tako can serve the original image bytes as a fallback when the source response has an image/* content type. That fallback is deliberately marked Cache-Control: private, no-store, so a transient resize failure does not become the permanent public optimized response. Validation failures, source-size failures, decoded-image safety failures, and a full transform queue do not fall back.

In practice, this keeps the failure mode narrow. A bad remote URL fails fast, an oversized source never reaches the expensive path, and a temporary encoder problem can still let a real browser see the original image instead of a broken page.

Deploy it like a Next app

There is no separate image service to boot. Install the SDK, wrap the config, add any remote allowlists, then deploy the app through the normal Tako CLI flow:

bun add tako.sh
tako init
tako deploy

For Next.js, the nextjs preset uses .next/tako-entry.mjs as the entrypoint. The adapter writes that file after next build. If Next emits .next/standalone/server.js, Tako stages the standalone server with public/ and .next/static/ copied into the right places. If standalone output is missing, the wrapper falls back to next start against the built .next/ directory.

The image path stays boring from the developer side:

  1. Use <Image> in your Next app.
  2. Use withTako() in next.config.ts.
  3. Put local images in public/, or add remote origins to both Next and Tako.
  4. Deploy to the VPS.

The interesting part is where the operational work moved. Your React code chooses the image. Next chooses responsive widths. tako.sh converts that into a platform URL. tako-server enforces the allowlist, transforms the bytes, caches the variant, and logs failures where tako logs can show them.

That is the kind of infrastructure Tako is trying to make feel ordinary on your own hardware. Not a second image vendor, not a custom Next route, not a hand-rolled sharp endpoint. Just your Next.js app, a VPS, and the platform layer that is already serving the rest of the request.