How to Deploy TanStack Start with Docker and Bun

Published: (January 6, 2026 at 09:45 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Cover image for How to Deploy TanStack Start with Docker and Bun

rogasper

TanStack Start is quickly becoming the go‑to framework for developers who want the power of TanStack Router with full‑stack capabilities. It’s bleeding‑edge, it’s fast, and the DX is incredible.

But let’s be real: moving from npm run dev on your laptop to a robust, containerised production environment is where the headaches usually start.

In this guide we aren’t just going to “make it run”. We are going to build a high‑performance deployment pipeline using Docker and Bun. We’ll implement a custom asset server that caches files in RAM, handles compression, and serves your app at lightning speed.

0️⃣ The Prerequisite (Don’t Skip This!)

Before we touch a single Dockerfile, there is one crucial requirement.

According to the official TanStack Start documentation, deploying with Bun currently requires React 19. If your project is still on React 18, the server might crash or behave unpredictably.

Make sure to upgrade your dependencies first:

bun install react@19 react-dom@19

Ready? Let’s build.

1️⃣ The Foundation: Vite Configuration

First, we need to make sure Vite is ready for production. We want our path aliases (like @/components) to work correctly and to strip out console logs so our production logs stay clean.

vite.config.ts

// vite.config.ts
import { defineConfig } from "vite";
import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from "node:url";

export default defineConfig({
  esbuild: {
    // Clean up logs in production
    drop: ["console", "debugger"],
  },
  server: {
    port: 3058,
  },
  plugins: [
    devtools(),
    // This plugin is vital for resolving paths like "@/lib/utils"
    viteTsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

2️⃣ The Secret Sauce: A Custom Bun Server

This is the cool part. Instead of running node server.js, we’ll use a custom script that leverages Bun’s native speed. The script acts as a smart asset server: it loads small files (icons, CSS, tiny JS chunks) directly into memory (RAM), serves them with automatic Gzip compression, and generates ETags for perfect browser caching.

Why?

  • Speed – RAM is faster than disk.
  • Compression – Gzip is handled automatically.
  • Efficiency – ETags let browsers cache assets perfectly.

Create a file named server.ts in your project root (see the full reference implementation in the TanStack Router repo) and paste the code below:

// server.ts
import path from "node:path";

// --- Configuration ---
const SERVER_PORT = Number(process.env.PORT ?? 3000);
const CLIENT_DIRECTORY = "./dist/client";
const SERVER_ENTRY_POINT = "./dist/server/server.js";

// Simple Logging Utility
const log = {
  info: (msg: string) => console.log(`[INFO] ${msg}`),
  error: (msg: string) => console.error(`[ERROR] ${msg}`),
  success: (msg: string) => console.log(`[SUCCESS] ${msg}`),
};

// --- Asset Preloading Logic ---
// 5 MiB limit for in‑memory files
const MAX_PRELOAD_BYTES = Number(
  process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024
);

// (Helper functions for ETag, Gzip, and Glob patterns omitted for brevity
// but ensure you include the full logic from the source provided earlier!)

/**
 * The Main Server Initializer
 */
async function initializeServer() {
  log.info("Starting Production Server...");

  // 1. Load the TanStack Start handler
  let handler: { fetch: (request: Request) => Response | Promise };
  try {
    const serverModule = (await import(SERVER_ENTRY_POINT)) as any;
    handler = serverModule.default;
    log.success("TanStack Start handler initialized");
  } catch (error) {
    log.error(`Failed to load handler: ${String(error)}`);
    process.exit(1);
  }

  // 2. Initialise static routes (scans ./dist/client)
  // Note: In your full file, ensure you include the `initializeStaticRoutes` function!
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY);

  // 3. Start Bun server
  const server = Bun.serve({
    port: SERVER_PORT,
    routes: {
      // Serve cached assets first
      ...routes,

      // Fallback to the App handler for everything else
      "/*": (req: Request) => {
        return handler.fetch(req);
      },
    },
  });

  log.success(`Server listening on http://localhost:${String(server.port)}`);
}

initializeServer().catch((err) => {
  log.error(String(err));
});

// ... (Paste the rest of the helper functions: initializeStaticRoutes, etc. here)

Important: Use the full server.ts code from the official materials to get the complete caching benefits.

3️⃣ The Dockerfile: Small & Efficient

We’ll use a multi‑stage build to keep the final image tiny. (The rest of the Dockerfile content follows the same pattern – copy the built assets, install only production dependencies, and run the Bun server.)

# ---- Builder Stage ----
FROM oven/bun:1 AS builder

WORKDIR /app

# Install dependencies (including React 19)
COPY package.json bun.lockb ./
RUN bun install --production=false

# Copy source files
COPY . .

# Build the client and server bundles
RUN bun run build

# ---- Runtime Stage ----
FROM oven/bun:1-alpine AS runtime

WORKDIR /app

# Copy only the built output and production deps
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .
COPY --from=builder /app/bun.lockb .

# Install only production dependencies
RUN bun install --production

# Expose the port the server listens on
EXPOSE 3000

# Start the Bun server
CMD ["bun", "run", "server.ts"]

Why This Docker Setup Works

We keep our final Docker image tiny because we don’t ship the build tools (TypeScript, Vite, etc.) into the production container—only what’s needed to run the app.

Key Technical Detail

Vite needs environment variables at build time (e.g., your API URL) so it can “bake” them into the client‑side JavaScript. Pay special attention to the ARG section of the Dockerfile.

Dockerfile (Multi‑stage)

# syntax=docker/dockerfile:1

# =============================================
# Stage 1: Builder
# =============================================
FROM oven/bun:1-alpine AS builder
WORKDIR /app

# Install dependencies (cached layer)
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Copy the source code
COPY . .

# --- IMPORTANT ---
# Vite needs these variables AT BUILD TIME to replace import.meta.env values
ARG VITE_API_URL
ARG VITE_BETTER_AUTH_URL
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BETTER_AUTH_URL=${VITE_BETTER_AUTH_URL}

# Build the app
RUN bun run build

# =============================================
# Stage 2: Runner (Production)
# =============================================
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Install curl for healthchecks
RUN apk add --no-cache curl

# Copy package files
COPY package.json bun.lock ./

# Install ONLY production deps (react, etc.)
RUN bun install --production

# Copy the build artifacts from the Builder stage
COPY --from=builder --chown=bun:bun /app/dist ./dist
COPY --from=builder --chown=bun:bun /app/server.ts ./server.ts

USER bun

# The port is dynamic via Env Vars
ENV PORT=3058
EXPOSE 3058

# Start the custom Bun server
CMD ["bun", "server.ts"]

4️⃣ Orchestration: Docker Compose

Using Docker Compose makes it easy to manage environment variables, ports, and health checks.

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        # Pass these to the Builder stage!
        VITE_BETTER_AUTH_URL: ${VITE_BETTER_AUTH_URL}
        VITE_API_URL: ${VITE_API_URL}   # (optional, if needed at build time)

    environment:
      # Runtime config for the server
      NODE_ENV: production
      PORT: ${PORT:-3058}

      # App secrets & configs
      VITE_API_URL: ${VITE_API_URL}
      DATABASE_URL: ${DATABASE_URL}
      BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}

      # SMTP / Email config
      HOST_EMAIL: ${HOST_EMAIL}
      HOST_PORT: ${HOST_PORT}
      HOST_AUTH_USER: ${HOST_AUTH_USER}
      HOST_AUTH_PASS: ${HOST_AUTH_PASS}

    ports:
      # Map host port to container port
      - '${PORT:-3058}:${PORT:-3058}'

    restart: always

    healthcheck:
      # Ping the server to ensure it's alive
      test: ['CMD-SHELL', 'curl -fsS http://localhost:${PORT:-3058} || exit 1']
      interval: 10s
      timeout: 5s
      retries: 5

Conclusion

You’re no longer running a development server inside a container. This setup gives you a highly optimized, memory‑caching, Bun‑powered production environment.

Quick Recap

  • React 19 – mandatory for Bun development
  • server.ts – provides advanced caching and gzip compression
  • Dockerfile – multi‑stage build that handles build‑time env vars correctly
  • Docker Compose – orchestrates runtime with health checks
  1. Create a .env file with the required variables.
  2. Run:
docker compose up --build -d
  1. Watch your TanStack Start app fly!

For more details, visit my blog.

Back to Blog

Related posts

Read more »

Rapg: TUI-based Secret Manager

We've all been there. You join a new project, and the first thing you hear is: > 'Check the pinned message in Slack for the .env file.' Or you have several .env...

Technology is an Enabler, not a Saviour

Why clarity of thinking matters more than the tools you use Technology is often treated as a magic switch—flip it on, and everything improves. New software, pl...