DEV Community

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

Posted on • Originally published at rogasper.com

How to Deploy TanStack Start with Docker and Bun

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, containerized 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 will implement a custom asset server that caches files in RAM, handles compression, and serves your app at lightning speeds.

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
Enter fullscreen mode Exit fullscreen mode

Ready? Let's build.

1. The Foundation: Vite Configuration

First, we need to make sure Vite is ready for production. We want to ensure our path aliases (like @/components) work correctly and that we strip out console logs so our production logs stay clean.
Here is the vite.config.ts setup:

// 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)),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

2. The Secret Sauce: A Custom Bun Server

This is the cool part. We aren't just going to run node server.js. We are going to use a custom script that leverages Bun's native speed.
This script (based on the official reference implementation) acts as a smart asset server. it loads samll files (like icons, CSS, and small JS chunks) directly into memory (RAM).
Why?

  • Speed: RAM is faster than disk
  • Compression: It handles Gzip automatically.
  • Efficiency: It generates ETags so browsers cache things perfectly Create a file named server.ts in your project root and paste this in:
// 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 ---
// 5MB 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<Response> }
    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. Initialize Static Routes (This 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)
Enter fullscreen mode Exit fullscreen mode

(Make sure to use the full server.ts code provided in the materials section to get the full caching benefits!)

3. The Dockerfile: Small & Efficient

We will use a Multi-Stage Build. This keeps our final Docker image tiny because we don't include all the build tools (like Typescript or Vite) in the final container, only what's needed to run the app.
Key Technical Detail: Pay attention to the ARG section. Vite needs environment variables (like your API URL) available during the build process to "bake" them into the client-side Javascript.

# 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"]
Enter fullscreen mode Exit fullscreen mode

4. Orchestration: Docker Compose

Finally, let's tie it all together. Using Docker Compose makes it easy to manage environment variables and ports.
We also added a healthcheck. This ensures that if the server crashes or hangs, Docker knows about it and can restart it automatically.

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        # Pass these to the Builder stage!
        VITE_BETTER_AUTH_URL: ${VITE_BETTER_AUTH_URL}

    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
Enter fullscreen mode Exit fullscreen mode

Conclusion

And there you have it. You aren't just running a development server in a container. You have a highly optimized, memory-caching, Bun-powered production environment.

Quick Recap:

  1. React 19: Mandatory for Bun Development
  2. Server.ts: Provides advanced caching and Gzip compression.
  3. Dockerfile: Uses multi-stage builds to handle build-time env vars correctlyDocker
  4. Compose: Orchestrates the runtime with health checks Now, just create your .env file, run docker compose up --build -d, and watch your Tanstack Start app fly!

for more information you can visit my blog

Top comments (0)