DEV Community

Cover image for Deploy TanStack Start with SQLite to Your Own Server
Andreas M
Andreas M

Posted on

Deploy TanStack Start with SQLite to Your Own Server

This guide walks you through deploying a TanStack Start application with SQLite database to your own server. Any Linux-based VPS or dedicated server will work.

We'll use Haloy for deployment. A open source tool that deploys and orchestrates Docker containers on your own servers with automatic HTTPS.

The complete source code is available at github.com/haloydev/examples/tanstack-start-sqlite.

Why This Stack?

There is no such thing as the perfect stack, but one combination I'm super excited about right now is TanStack Start and SQLite. Here's why:

  • Simplicity is King. You are building an app, not managing a database cluster. SQLite runs in the same process as your app. There’s no network latency between your web server and your database. Development setup is non-existent. Run pnpm install and start coding. It’s glorious.

  • Performance out of the box. Because the database lives right next to your server code, reads and writes are incredibly fast. You get local-file-system speeds which often blow network-attached database servers out of the water for typical web requests.

  • Deployment is easy. You deploy your app server and the database file (mounted as a persistent volume) to a single region with Haloy

What You'll Build

A full-stack React application using:

  • TanStack Start - React meta-framework with file-based routing and server functions
  • SQLite - Lightweight, file-based database
  • Drizzle ORM - TypeScript ORM for type-safe database queries
  • Haloy - Simple deployment to your own server

Prerequisites

  • Node.js 20+ installed
  • Haloy installed (Quickstart)
  • A linux server (VPS or dedicated server)
  • A domain or a subdomain
  • Basic familiarity with React and TypeScript

Project Setup

1. Initialize the Project

mkdir my-tanstack-app
cd my-tanstack-app
pnpm init
Enter fullscreen mode Exit fullscreen mode

2. Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "Bundler",
    "module": "ESNext",
    "target": "ES2022",
    "skipLibCheck": true,
    "strictNullChecks": true
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Install Dependencies

Install TanStack Start and React:

pnpm add @tanstack/react-start @tanstack/react-router react react-dom nitro
Enter fullscreen mode Exit fullscreen mode

Install dev dependencies:

pnpm add -D vite @vitejs/plugin-react typescript @types/react @types/react-dom @types/node vite-tsconfig-paths
Enter fullscreen mode Exit fullscreen mode

Install Drizzle and SQLite:

pnpm add drizzle-orm @libsql/client dotenv drizzle-kit
Enter fullscreen mode Exit fullscreen mode

Note: drizzle-kit is installed as a production dependency (not -D) because we need it available in the Docker container to run migrations at startup.

4. Update package.json

Update your package.json with the required configuration and scripts:

{
  // ...
  "type": "module",
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "start": "node .output/server/index.mjs",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Create Vite Configuration

Create vite.config.ts:

import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
import tsConfigPaths from "vite-tsconfig-paths";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tsConfigPaths(),
    tanstackStart(),
    nitro(),
    // react's vite plugin must come after start's vite plugin
    viteReact(),
  ],
  nitro: {},
});
Enter fullscreen mode Exit fullscreen mode

About Nitro

TanStack Start uses Nitro as its server engine. For this deployment, we're using the default Node.js preset, which works perfectly with Haloy. No additional configuration is needed. The empty nitro: {} object is sufficient.

Database Setup

1. Configure Drizzle

Create drizzle.config.ts:

import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";

config();

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
  throw new Error("DATABASE_URL is not set");
}

export default defineConfig({
  out: "./drizzle",
  schema: "./src/db/schema.ts",
  dialect: "sqlite",
  dbCredentials: {
    url: databaseUrl,
  },
});
Enter fullscreen mode Exit fullscreen mode

2. Create Database Client

Create src/db/index.ts:

import "dotenv/config";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
  throw new Error("DATABASE_URL is not set");
}

const client = createClient({ url: databaseUrl });
const db = drizzle({ client });
export { client, db };
Enter fullscreen mode Exit fullscreen mode

3. Define Your Schema

Create src/db/schema.ts:

import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const todos = sqliteTable("todos", {
  id: integer("id", { mode: "number" }).primaryKey({
    autoIncrement: true,
  }),
  title: text("title").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }).default(
    sql`(unixepoch())`
  ),
});
Enter fullscreen mode Exit fullscreen mode

4. Create Environment File

Create .env for local development:

DATABASE_URL=file:local.db
Enter fullscreen mode Exit fullscreen mode

5. Generate and Run Migrations

pnpm db:generate
pnpm db:migrate
Enter fullscreen mode Exit fullscreen mode

This creates migration files in the drizzle/ directory that will be used in production.

Application Code

1. Create the Router

Create src/router.tsx:

import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

export function getRouter() {
  const router = createRouter({
    routeTree,
    scrollRestoration: true,
    defaultNotFoundComponent: () => <div>404 - not found</div>,
  });

  return router;
}
Enter fullscreen mode Exit fullscreen mode

Note: You might see a TypeScript error about ./routeTree.gen not being found. This is expected. TanStack Start automatically generates this file when you run the dev server in the next steps.

2. Create the Root Route

Create src/routes/__root.tsx:

/// <reference types="vite/client" />

import {
  createRootRoute,
  HeadContent,
  Outlet,
  Scripts,
} from "@tanstack/react-router";
import type { ReactNode } from "react";

export const Route = createRootRoute({
  head: () => ({
    meta: [
      {
        charSet: "utf-8",
      },
      {
        name: "viewport",
        content: "width=device-width, initial-scale=1",
      },
      {
        title: "TanStack Start Starter",
      },
    ],
  }),
  component: RootComponent,
});

function RootComponent() {
  return (
    <RootDocument>
      <Outlet />
    </RootDocument>
  );
}

function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Create the Index Route

Create src/routes/index.tsx:

import { createFileRoute, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { eq } from "drizzle-orm";
import { db } from "../db";
import { todos } from "../db/schema";

const getTodos = createServerFn({
  method: "GET",
}).handler(async () => await db.select().from(todos));

const addTodo = createServerFn({ method: "POST" })
  .inputValidator((data: FormData) => {
    if (!(data instanceof FormData)) {
      throw new Error("Expected FormData");
    }
    return {
      title: data.get("title")?.toString() || "",
    };
  })
  .handler(async ({ data }) => {
    await db.insert(todos).values({ title: data.title });
  });

const deleteTodo = createServerFn({ method: "POST" })
  .inputValidator((data: number) => data)
  .handler(async ({ data }) => {
    await db.delete(todos).where(eq(todos.id, data));
  });

export const Route = createFileRoute("/")({
  component: RouteComponent,
  loader: async () => await getTodos(),
});

function RouteComponent() {
  const router = useRouter();
  const todos = Route.useLoaderData();

  return (
    <div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.title}
            <button
              type="button"
              onClick={async () => {
                  await deleteTodo({ data: todo.id });
                  router.invalidate();
                }}
              >
                X
              </button>
            </li>
          ))}
        </ul>
        <h2>Add todo</h2>
        <form
          onSubmit={async (e) => {
            e.preventDefault();
            const form = e.currentTarget;
            const data = new FormData(form);
            await addTodo({ data });
            router.invalidate();
            form.reset();
          }}
        >
          <input name="title" placeholder="Enter a new todo..." />
          <button type="submit">Add</button>
        </form>
      </div>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Docker Configuration

1. Create Dockerfile

Create Dockerfile:

FROM node:24-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app

FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build

FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/.output /app/.output
CMD [ "sh", "-c", "pnpm db:migrate && pnpm start" ]
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Uses multi-stage builds for smaller final image
  • Runs database migrations at container startup. This is safe because Drizzle migrations are idempotent (running them multiple times has no effect if the database is already up to date)
  • Production dependencies include drizzle-kit for migrations

2. Create .dockerignore

Create .dockerignore:

We need to exclude files from the built Docker image. Notice *.db is excluded. Your local database should never be copied to production. The production database lives in the persistent volume.

node_modules
.git
.gitignore
*.md
dist
.DS_Store
*.db
Enter fullscreen mode Exit fullscreen mode

Haloy Configuration

Create haloy.yml:

This file tells the haloy CLI tool how to deploy your app. It's pretty simple and straightforward.

name: my-tanstack-app
server: your-server.haloy.dev
domains:
  - domain: my-app.example.com
port: 3000
env:
  - name: NODE_ENV
    value: production
  - name: DATABASE_URL
    value: "file:/app/db-data/production.db"
volumes:
  - "db-data:/app/db-data"
Enter fullscreen mode Exit fullscreen mode

Configuration Explained

Field Description
name Unique identifier for your application
server Your Haloy server domain
domains Public domain(s) for your app (HTTPS is automatic)
port The port your app listens on inside the container. Nitro defaults to port 3000, which matches the Vite config
env Environment variables passed to your container
volumes Persistent storage - critical for SQLite data

Volume Configuration

The volumes configuration is critical for SQLite. See Volumes for more details on persistent storage.

volumes:
  - "db-data:/app/db-data"
Enter fullscreen mode Exit fullscreen mode

This creates a named volume db-data mounted at /app/db-data inside the container. The DATABASE_URL points to a file in this directory, ensuring your database persists across deployments and container restarts.

Deploy

1. Test Locally

Before deploying, verify everything works locally. If you haven't already, make sure you've completed the database setup steps above (create .env, generate and run migrations).

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:3000 and try adding a todo to verify both the app and database are working correctly.

2. Deploy with Haloy

If everything is working locally, you can now deploy to your server. Make sure you have Haloy installed and have configured your domain's DNS to point to your server. Check out the Quickstart if you haven't set it up yet.

haloy deploy
Enter fullscreen mode Exit fullscreen mode

Haloy will:

  1. Build your Docker image locally
  2. Push it to your server
  3. Run the container with your configuration
  4. Set up HTTPS automatically
  5. Route traffic to your app

3. Verify Deployment

# Check status
haloy status

# View logs
haloy logs
Enter fullscreen mode Exit fullscreen mode

Your app should now be live.

Production Considerations

Database Backups

SQLite stores all data in a single file. To back up your database:

# Execute a backup command in the container
haloy exec -- cp /app/db-data/production.db /app/db-data/backup-$(date +%Y%m%d).db
Enter fullscreen mode Exit fullscreen mode

Consider setting up automated backups using a cron job or scheduled task.

Monitoring

View your application logs:

# Stream logs
haloy logs

# Check application status
haloy status
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

Database Not Persisting

Ensure your volumes configuration matches your DATABASE_URL:

env:
  - name: DATABASE_URL
    value: "file:/app/db-data/production.db"  # Must be inside the volume mount
volumes:
  - "db-data:/app/db-data"  # Volume mounted here
Enter fullscreen mode Exit fullscreen mode

You can use haloy exec to run commands inside your container for debugging. Verify the database file exists and is being written to the correct location:

haloy exec -- ls -la /app/db-data/
Enter fullscreen mode Exit fullscreen mode

Migration Errors

If migrations fail at startup, check:

  1. The drizzle/ directory is included in your Docker image
  2. drizzle-kit is a production dependency (not devDependency)
  3. Logs for specific error messages: haloy logs

Verify the migration files are present in the container:

haloy exec -- ls -la /app/drizzle/
Enter fullscreen mode Exit fullscreen mode

Connection Issues

If you can't connect to your deployed app:

  1. Verify the domain is correctly configured: haloy status
  2. Check the app is running: haloy logs
  3. Ensure port 3000 matches your app's listening port

Scaling Limitations

SQLite is designed for single-server deployments. If you need to run multiple replicas of your application, you have two options:

  1. Switch to a client-server database like PostgreSQL or MySQL
  2. Use a distributed SQLite solution like Turso or LiteFS

For most applications, a single replica with SQLite can handle significant traffic. Often more than you'd expect.

Top comments (0)