{"slug":"self-hosted-nextjs-image-optimization-vps","url":"https://tako.sh/blog/self-hosted-nextjs-image-optimization-vps/","canonical":"https://tako.sh/blog/self-hosted-nextjs-image-optimization-vps/","title":"Self-Hosted Next.js Image Optimization on a VPS","date":"2026-05-20T14:19","description":"Use next/image with Tako's self-hosted optimizer on a VPS: custom loader wiring, remote allowlists, WebP output, caches, and fallbacks.","author":null,"image":"c282f5235ed5","imageAlt":null,"headings":[{"depth":2,"slug":"the-nextimage-handoff","text":"The next/image handoff"},{"depth":2,"slug":"two-allowlists-two-jobs","text":"Two allowlists, two jobs"},{"depth":2,"slug":"what-the-vps-does","text":"What the VPS does"},{"depth":2,"slug":"deploy-it-like-a-next-app","text":"Deploy it like a Next app"}],"markdown":"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.\n\nThat 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](/docs/deployment/).\n\nTako 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.\n\n## The `next/image` handoff\n\nNext'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.\n\nTako wraps that config for you:\n\n```ts\n// next.config.ts\nimport { withTako } from \"tako.sh/nextjs\";\n\nexport default withTako({\n  images: {\n    remotePatterns: [\n      {\n        protocol: \"https\",\n        hostname: \"cdn.example.com\",\n        pathname: \"/uploads/**\",\n      },\n    ],\n  },\n});\n```\n\nUnder the hood, `withTako()` preserves your config, then applies the pieces Tako needs:\n\n| Next config field    | Value Tako sets                 | Why it matters                                              |\n| -------------------- | ------------------------------- | ----------------------------------------------------------- |\n| `output`             | `\"standalone\"`                  | Gives Tako a deployable server output.                      |\n| `adapterPath`        | the `tako.sh/nextjs` adapter    | Lets Next write `.next/tako-entry.mjs` after build.         |\n| `allowedDevOrigins`  | adds `*.test` and `*.tako.test` | Lets `tako dev` proxy requests through local HTTPS hosts.   |\n| `images.loader`      | `\"custom\"`                      | Tells `next/image` not to use Next's default optimizer URL. |\n| `images.loaderFile`  | Tako's packaged loader          | Converts image props into `/_tako/image` URLs.              |\n| `images.deviceSizes` | `[320, 640, 960, 1200, 1920]`   | Aligns Next's generated widths with Tako's defaults.        |\n| `images.imageSizes`  | `[]`                            | Keeps the generated variant set small and predictable.      |\n\nThat means your component still looks like ordinary Next.js:\n\n```tsx\nimport Image from \"next/image\";\n\nexport function Hero() {\n  return (\n    <Image\n      src=\"/images/product-hero.jpg\"\n      alt=\"Product dashboard\"\n      width={1200}\n      height={800}\n      priority\n    />\n  );\n}\n```\n\nWhen Next renders the page, Tako's loader turns the image into a public optimizer URL:\n\n```text\n/_tako/image?src=%2Fimages%2Fproduct-hero.jpg&w=1200\n```\n\nIf 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.\n\n## Two allowlists, two jobs\n\nRemote image rules exist in two places because they protect different boundaries.\n\nNext's [`remotePatterns`](https://nextjs.org/docs/app/api-reference/components/image) 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.\n\nTako'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`](/docs/tako-toml/):\n\n```toml\nruntime = \"node\"\npreset = \"nextjs\"\n\n[images]\nremote_patterns = [\"https://cdn.example.com/uploads/**\"]\nformats = [\"webp\"]\n```\n\nFor a typical self-hosted Next app, keep both sides aligned:\n\n| Need                      | Configure in Next           | Configure in Tako                                                       |\n| ------------------------- | --------------------------- | ----------------------------------------------------------------------- |\n| Local images in `public/` | nothing special             | local paths work by default                                             |\n| Remote CMS images         | `images.remotePatterns`     | `[images].remote_patterns`                                              |\n| Default responsive widths | `withTako()` sets them      | defaults are already the same                                           |\n| WebP output               | no component change         | default `formats = [\"webp\"]`                                            |\n| AVIF output               | usually no component change | add `formats = [\"avif\", \"webp\"]` if you want negotiation to prefer AVIF |\n\nThe 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.\n\nThat 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.\"\n\n## What the VPS does\n\nAfter 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](/docs/how-tako-works/) as your Next process.\n\n```d2\ndirection: right\n\ncomponent: \"Next <Image>\\nwidth candidates\" {\n  style.fill: \"#FFF9F4\"\n  style.stroke: \"#2F2A44\"\n}\nloader: \"Tako loader\\n/_tako/image URL\" {\n  style.fill: \"#9BC4B6\"\n}\nserver: \"tako-server\\nallowlist + fetch\" {\n  style.fill: \"#E88783\"\n}\nworker: \"libvips worker\\nresize + encode\" {\n  style.fill: \"#9BC4B6\"\n}\ncache: \"transform cache\\nWebP or AVIF response\" {\n  style.fill: \"#FFF9F4\"\n  style.stroke: \"#2F2A44\"\n}\n\ncomponent -> loader: \"src, width, quality\"\nloader -> server: \"browser requests URL\"\nserver -> worker: \"validated original\"\nworker -> cache: \"store variant\"\ncache -> component: \"serve hit or new transform\"\n```\n\nThe 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]`.\n\nThen 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.\n\nThe performance path has two caches:\n\n| Cache           | Scope                                      | Purpose                                                       |\n| --------------- | ------------------------------------------ | ------------------------------------------------------------- |\n| Source cache    | in memory, 10 seconds, 64 MiB, 256 entries | Reuses the same original when a page asks for several widths. |\n| Transform cache | local disk under `/tmp/tako-image-cache`   | Reuses finished variants across requests.                     |\n\nTransform 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.\n\nThe 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.\n\nIf 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.\n\nIn 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.\n\n## Deploy it like a Next app\n\nThere 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](/docs/cli/) flow:\n\n```bash\nbun add tako.sh\ntako init\ntako deploy\n```\n\nFor 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.\n\nThe image path stays boring from the developer side:\n\n1. Use `<Image>` in your Next app.\n2. Use `withTako()` in `next.config.ts`.\n3. Put local images in `public/`, or add remote origins to both Next and Tako.\n4. Deploy to the VPS.\n\nThe 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.\n\nThat 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."}