Dockerizing a TanStack Start app with Nitro

TanStack Start doesn’t bundle a production server. vite build on its own gives you a Fetch-API request handler - a (Request) => Promise<Response> - and no program that binds a port. Nitro is what turns that into a standalone server, and it’s a package you install yourself rather than something that ships in the box.

Once it’s in, vite build writes a .output/ directory that runs with plain node, depends on nothing else, and drops into a container in one COPY.

Versions this was written against: @tanstack/react-start 1.168.46, nitro 3.0.260610-beta, srvx 0.12.5, vite 8.1.4.

Step 1: install Nitro and srvx

pnpm add nitro srvx

Nitro v3 is still in beta, so pin it exactly rather than with a range - the build output is the thing your Dockerfile depends on, and beta minors move:

"nitro": "3.0.260610-beta",
"srvx": "^0.12.5"

srvx is optional. It’s the Fetch-API server layer Nitro uses underneath, and the docs suggest borrowing its FastResponse for “a ~5% throughput improvement” on Node deployments. Step 3 is where that gets wired in - and where the one real trap lives.

Step 2: add the Nitro plugin

// vite.config.ts
import { defineConfig } from 'vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { nitro } from 'nitro/vite';
import viteReact from '@vitejs/plugin-react';

export default defineConfig({
	plugins: [tanstackStart(), nitro(), viteReact()]
});

Order matters: nitro() goes after tanstackStart() and before viteReact().

That alone is enough to change the build output. With no further configuration, Nitro picks the node-server preset and writes .output/.

Step 3: the server entry, and the snippet that breaks it

The hosting guide gives you this for src/server.ts:

// src/server.ts - as documented. Do NOT ship this as-is.
import { FastResponse } from 'srvx';

globalThis.Response = FastResponse;

Copy that verbatim into a project that didn’t already have a src/server.ts and the build succeeds, the container starts, and every request 500s:

TypeError: mod.fetch is not a function
    at file:///app/.output/server/_chunks/ssr-renderer.mjs:8:36
  status: 500,
  unhandled: true

The problem is that src/server.ts isn’t a configuration file you sprinkle globals into - it’s the server entry. If it doesn’t exist, TanStack Start supplies a default one. The moment you create it, yours replaces the default wholesale, and the snippet above exports nothing.

You can watch it happen in the build output. Nitro’s SSR service loader does this:

// .output/server/_chunks/ssr-renderer.mjs
function lazyService(loader) {
	let promise, mod;
	return {
		fetch(req) {
			if (mod) return mod.fetch(req);
			if (!promise) promise = loader().then((_mod) => (mod = _mod.default || _mod));
			return promise.then((mod) => mod.fetch(req));
		}
	};
}

It imports the SSR service and calls .fetch() on its default export. And the SSR service it’s importing - your src/server.ts, compiled - is this in its entirety:

// .output/server/_ssr/ssr.mjs - the whole file
globalThis.Response = NodeResponse;
export {};

No default export, no fetch, so mod.fetch is undefined. The dead giveaway is the file size: a working _ssr/ssr.mjs is a couple hundred KB of your actual application. If yours is three lines, this is why.

The fix is to re-export the handler the default entry would have given you:

// src/server.ts
import { FastResponse } from 'srvx';
import handler from '@tanstack/react-start/server-entry';

globalThis.Response = FastResponse;

export default handler;

The server entry docs spell out the contract the hosting page assumes you already know - the default export has to satisfy { fetch(req: Request): Response | Promise<Response> }. If you want to wrap the handler with your own logic rather than just pass it through, there’s a typed helper:

import handler, { createServerEntry } from '@tanstack/react-start/server-entry';

export default createServerEntry({
	fetch(request) {
		// your middleware here
		return handler.fetch(request);
	}
});

Two docs pages, each correct on its own, that combine into a broken file. Worth knowing before you spend an evening on it.

Step 4: the start script

"scripts": {
	"build": "vite build",
	"start": "node .output/server/index.mjs"
}

What you actually get

.output/
├── nitro.json           # preset metadata
├── public/              # client assets, served by the server below
│   └── assets/
└── server/
    ├── index.mjs        # a real http listener
    ├── _ssr/ssr.mjs     # your compiled server entry
    ├── _chunks/
    ├── _libs/
    └── node_modules/    # just tslib

index.mjs binds a port and serves public/ itself. PORT and HOST are read from the environment; it listens on all interfaces by default.

The part that matters for the image is that this is self-contained. Verify it rather than trusting it - copy .output somewhere with no node_modules anywhere up the tree, and run it:

mkdir /tmp/standalone && cp -r .output /tmp/standalone/
cd /tmp/standalone && PORT=3000 node .output/server/index.mjs
# ➜ Listening on: http://localhost:3000/ (all interfaces)

curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/            # 200
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/favicon.svg # 200

SSR and static assets, both from .output alone - no project node_modules, no vite, no src, no package.json. Around 4.5 MB total for a mid-sized app. Nitro inlines the dependency tree into _libs/; the only thing left in .output/server/node_modules is tslib.

That single fact is what makes the Dockerfile trivial.

The Dockerfile

# syntax=docker/dockerfile:1

ARG NODE_VERSION=22-alpine
ARG PNPM_VERSION=11.17.0

# ---- build ----------------------------------------------------------------
FROM node:${NODE_VERSION} AS build
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /app

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

COPY . .

# VITE_* vars are inlined into the bundle at build time - a build arg,
# not a runtime env var. One image build per environment.
ARG VITE_API_BASE_URL=https://api.example.com
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}

RUN pnpm build

# ---- runtime --------------------------------------------------------------
FROM node:${NODE_VERSION} AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app

COPY --chown=node:node --from=build /app/.output ./.output

USER node

EXPOSE 3000

CMD ["node", ".output/server/index.mjs"]

Two stages, one COPY in the runtime stage, and no package manager in the final image - CMD invokes node directly. The runtime stage is stock node:22-alpine plus 4.5 MB. Keep --chown=node:node on the COPY so the non-root node user actually owns what it’s running.

Add .output to .dockerignore alongside node_modules and dist - the image should build it fresh, not inherit whatever is sitting in your working tree.

One thing worth knowing if you’re putting this behind a reverse proxy: unlike Vite’s dev and preview servers, Nitro’s node-server output has no Host header allowlist, so proxied domains work without configuration.

curl -o /dev/null -w '%{http_code}\n' -H 'Host: app.example.com' http://localhost:3000/
# 200

Host filtering, if you want it, belongs at the proxy.

GitHub Actions: build and publish to GHCR

name: Build and publish Docker image

on:
  push:
    branches: [main]
    tags: ['v*.*.*']
  pull_request:
    branches: [main]
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # PRs are built to verify the image compiles, but never pushed.
      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,format=long
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            VITE_API_BASE_URL=${{ vars.VITE_API_BASE_URL || 'https://api.example.com' }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

docker/metadata-action lowercases the image name automatically, so github.repository’s casing doesn’t matter. GITHUB_TOKEN is enough to push - no PAT, no registry secret. Set a VITE_API_BASE_URL repository variable (Settings → Secrets and variables → Actions → Variables) to control what gets baked into the published image.

Recap

  • pnpm add nitro, add nitro() to the Vite plugins, and vite build produces a standalone .output/server/index.mjs.
  • Creating src/server.ts replaces the default server entry. The hosting guide’s FastResponse snippet is a fragment, not a file - without export default handler you get mod.fetch is not a function on every request. Check _ssr/ssr.mjs: three lines means you hit this.
  • .output is genuinely standalone, so the runtime stage is node:22-alpine plus one COPY - no package manager, no source, no node_modules.
  • VITE_* vars are baked in at build time, which makes them build args and means one image per environment. Everything else - PORT, HOST - stays a runtime knob.