Bitdoze Logo

Deploy TanStack Start on Your VPS with Dokploy - Complete Guide

Deploy TanStack Start on a VPS with Dokploy: Postgres 17, Drizzle migrations, Railpack or Dockerfile builds, HTTPS, plus verify and rollback steps in prod.

DragosDragos33 min read
TanStack Start app deployed on a Dokploy VPS with PostgreSQL on an internal Docker network

TanStack Start gives you SSR, file-based routing, and type-safe server functions in one Vite project. What it doesn’t give you is a place to run it. Vercel will happily take the deploy and the bill, but a small SSR app doesn’t need edge functions or per-request pricing. It needs a process on port 3000 and a Postgres box next to it.

This guide deploys a TanStack Start app with Drizzle ORM and PostgreSQL 17 on a VPS you control, using Dokploy for the build, the reverse proxy, and the database. Push to main, it builds, migrations run, HTTPS is already handled.

This is a rewrite of my original walkthrough. Three things changed:

  • The app talks to Postgres over Dokploy’s internal Docker network, so port 5432 never faces the internet.
  • Production runs generated, versioned migrations instead of drizzle-kit push, which can drop columns on a schema change you didn’t think through.
  • There’s a multi-stage Dockerfile for when Railpack’s auto-detection guesses wrong, and I explain when that matters.

Why run TanStack Start on your own VPS

Dokploy Setup Video

Here’s why this setup works well:

  • Full control: your infrastructure, your data, no platform lock-in on the deploy path
  • Predictable cost: a fixed monthly VPS bill instead of per-request pricing. A 2 vCPU / 4 GB instance at Hetzner Cloud is single-digit euros per month and runs the app plus Postgres comfortably
  • No runtime limits: no function timeouts, no bandwidth surprises, no cold starts
  • Database included: PostgreSQL 17 managed through the same panel, with scheduled backups
  • Git push deploys: the same workflow you had on Vercel, minus the vendor
  • Modern stack: TanStack Start, Drizzle ORM, tRPC if you want it, all type-safe end to end

The tradeoff is honest: you own the patching, the backups, and the 3am page. For a small SaaS or internal tool, that’s a fine trade. For a site with a global audience and zero appetite for ops, it isn’t.

Prerequisite: Install Dokploy

Before starting, make sure your Dokploy server is installed and configured. Follow the complete setup guide: Dokploy Install – Ditch Vercel/Heroku and Self-Host Your SaaS. This includes VPS setup, security hardening with CrowdSec, and Dokploy installation. For updating deployed apps, see How to Update Docker Compose Stacks in Dokploy.

What you’ll need

  • A VPS with Dokploy installed. 2 vCPU / 4 GB is the realistic floor for building and running an SSR Node app, and that’s what I’d provision. Hetzner Cloud is my default, Hostinger VPS is the budget alternative. If you’re still choosing a box, my DigitalOcean vs Vultr vs Hetzner comparison covers the price and billing differences.
  • A domain or subdomain with an A record pointing to the VPS IP (for example app.yourdomain.com)
  • Node.js 22 or 24 LTS on your laptop (node -v should print 22.x or higher)
  • A GitHub (or GitLab/Gitea) account for the repo
  • PostgreSQL 17 in Dokploy, or the willingness to create it in step 2

About 45 minutes end to end. The first Dokploy build is the slow part, 2 to 5 minutes.


Video with setting everything up on Dokploy

The deployment at a glance

Browserhttps://app…DNS A recordVPS IPv4Traefik :80/:443Let’s Encrypt TLSapp containernode :3000 (SSR)postgres 17internal :5432GitHubpush → buildOnly 80 and 443 are public. Postgres is reachable on the internal Docker network, not from the internet.

Step 1: sanity-check the server before you deploy anything

SSH into the VPS and confirm three things: Docker is up, Dokploy’s panel answers, and you have enough memory to run a Node build without the OOM killer stopping it mid-build.

ssh root@your-vps-ip

# Docker and Dokploy running?
docker ps --format '{{.Names}}\t{{.Status}}' | head

# Memory and swap
free -h

# Panel listening locally
curl -sI http://localhost:3000 | head -1

Expected output: dokploy and dokploy-traefik containers listed as Up, and a HTTP/1.1 200 OK from the panel. If free -h shows 0B swap on a 2 to 4 GB box, add a swapfile before your first build. Node builds are memory-hungry and a killed build looks like a random “deployment failed” with no useful log.

fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
free -h

If swap stays at 0B after swapon, the file didn’t get allocated (often a full disk). Check df -h / before blaming the kernel.


Step 2: create PostgreSQL 17 in Dokploy

Create the database before the app so you have the connection details when you wire up Drizzle. I use Postgres 17 because it’s what I’ve been running in production and there’s no reason to chase 18 until you need something from it. If your Dokploy version offers 18 and you want it, it works the same way.

  1. Open your Dokploy URL (https://app.yourdomain.com) and go to Databases in the sidebar
  2. New DatabasePostgreSQL
  3. Set:
    • Version: 17
    • Name: saas-app-db (this becomes the internal hostname, so keep it short and lowercase)
    • Database Name: saas-app
    • Username: saas-app
    • Password: let Dokploy generate one, then copy it
    • External Port: leave this off
  4. Create it and wait for the service to come up

Leave the external port off

The original version of this guide told you to expose 5432 and punch an iptables hole for it. Don’t. Your app container and the Postgres container are on the same Dokploy server and the same Docker network, so the app connects to saas-app-db:5432 over the internal network. The database never needs a public port, and you never have a Postgres port open to the internet waiting for someone to find it.

Two connection strings exist in the dashboard. Use the internal one:

# Internal (what the app uses in production)
postgresql://saas-app:<password>@saas-app-db:5432/saas-app

# External (only if you insist on connecting from your laptop)
postgresql://saas-app:<password>@91.98.95.196:5432/saas-app

Verify the database is actually listening before you go further. Open the database’s Terminal tab in Dokploy and run:

psql -U saas-app -d saas-app -c 'select version();'

You should see PostgreSQL 17.x in the output. psql: FATAL: role "saas-app" does not exist means you’re in the wrong container or the user was mistyped at creation.

If you must open 5432

If the database lives on a different server than the app, you need the external port. Open it for your IP only, and remember that Docker writes its own iptables rules, so a UFW rule alone may not be enough (Docker bypasses UFW). Restrict at the source: iptables -I INPUT -p tcp –dport 5432 -s YOUR.HOME.IP -j ACCEPT plus a DROP for everything else, then netfilter-persistent save. Better still, wire it through WireGuard or a private VCN subnet.


Step 3: scaffold the TanStack Start app

Current TanStack docs push people to the TanStack CLI. Older templates used npm create @tanstack/start@latest. Both land you in the same interactive flow, so use whichever your version documents:

npx @tanstack/cli@latest create tanstack-dokploy-test

If that command errors out on your version, fall back to:

npm create @tanstack/start@latest

The prompts look like this, with my answers:

◇  What would you like to name your project?
│  tanstack-dokploy-test

◇  Would you like to use Tailwind CSS?
│  Yes

◇  Select toolchain
│  Biome

◇  What add-ons would you like for your project?
│  Drizzle, Shadcn, tRPC, Query

◇  Would you like any examples?
│  none

◇  Drizzle: Database Provider
│  PostgreSQL

Why these choices: Drizzle gives you a schema file you can diff, tRPC keeps the API typed without hand-written clients, and shadcn gives you components you own instead of a dependency. If you’re weighing Start against Next.js or Astro for this project, I compared all three in Astro vs Next.js vs TanStack Start.

cd tanstack-dokploy-test
npm run dev

Open http://localhost:3000. You should get the starter page with no console errors. If the port is taken, Vite picks the next free one and prints it, so read the terminal output instead of assuming 3000.


Step 4: wire Drizzle to Postgres

4.1 Run a local Postgres for development

Don’t develop against the production database. Spin up a throwaway Postgres in Docker locally:

docker run -d --name pg-dev \
  -e POSTGRES_USER=saas-app \
  -e POSTGRES_PASSWORD=devpass \
  -e POSTGRES_DB=saas-app \
  -p 5432:5432 \
  postgres:17

Create .env in the project root:

# .env  (local development only)
DATABASE_URL="postgresql://saas-app:devpass@localhost:5432/saas-app"

And make sure it never gets committed:

grep -qxF '.env' .gitignore || echo '.env' >> .gitignore
git check-ignore -v .env

That last command should print a line from .gitignore. If it prints nothing, the file is staged and you have a password heading for GitHub.

4.2 Point Drizzle at your schema

The Drizzle add-on generates a config file. Adjust it to match your paths and make sure the dialect matches your database:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  strict: true,
  verbose: true,
});

A minimal schema to have something real to migrate:

// src/db/schema.ts
import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";

export const todos = pgTable("todos", {
  id: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  done: boolean("done").notNull().default(false),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

4.3 Generate migrations and apply them

npm run db:generate   # writes SQL to ./drizzle
npm run db:migrate    # applies pending migrations
npm run db:studio     # optional GUI on http://localhost:4983

Verify the table exists, then confirm the migration was recorded:

docker exec -it pg-dev psql -U saas-app -d saas-app -c '\dt'
docker exec -it pg-dev psql -U saas-app -d saas-app -c 'select hash from drizzle.__drizzle_migrations;'

You should see todos in the table list and one row in the migrations table per applied migration. If \dt shows nothing, db:migrate connected to a different database than you think (usually a stale DATABASE_URL in your shell).

Never run drizzle-kit push against production

db:push diffs your schema straight into the live database and will happily drop a column to make your code and the database match. That’s fine for local hacking, and it’s a data-loss incident in production. Generate SQL locally, commit the files in drizzle/, and run db:migrate in production (step 8). This is also the whole reason you keep a Drizzle schema in version control.

Migrations live in git, so every environment applies the same ordered statements. If you’re working with a schema that changes often and want to see the workflow in a smaller app first, the TanStack Start + Drizzle todo app guide walks the same pattern on a managed libSQL database.


Step 5: make the app production-ready

5.1 Build a standalone server

TanStack Start’s Vite plugin renders and serves your app in dev, but production needs a real Node process. Nitro’s node-server preset produces one at .output/server/index.mjs.

npm install @tanstack/nitro-v2-vite-plugin
// vite.config.ts
import { defineConfig } from "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 { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";

export default defineConfig({
  plugins: [
    viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
    tailwindcss(),
    tanstackStart(),
    nitroV2Plugin({ preset: "node-server" }),
    viteReact(),
  ],
});

Nitro v2 plugin vs Nitro v3

TanStack Start’s hosting story has moved. Newer versions pair with Nitro v3, where the plugin comes from Nitro itself, imported as nitro from nitro/vite and called with the preset node-server. Older Start 1.x templates use @tanstack/nitro-v2-vite-plugin as above. Check what your template installed with npm ls nitro and follow the hosting page for that version. Either way the output lands in .output/ and the start command is the same, which is all Dokploy cares about.

5.2 Pin the scripts and the Node version

Both Railpack and Nixpacks read package.json to decide what to do. Be explicit so neither has to guess:

{
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "start": "node .output/server/index.mjs",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"
  },
  "engines": {
    "node": ">=22"
  }
}

Add a .node-version file so builds don’t drift when the builder default changes:

echo "22" > .node-version

Builders on the box may read .node-version or .nvmrc; Nixpacks also accepts a NIXPACKS_NODE_VERSION build variable. Pinning in the repo beats pinning in the panel, because the repo travels with the app.

5.3 Add a health endpoint

You want one URL that tells you “the process is alive and the database answers”. A server route is the cheapest version:

// src/routes/api/health.ts
import { createFileRoute } from "@tanstack/react-router";
import { sql } from "drizzle-orm";
import { db } from "@/db";

export const Route = createFileRoute("/api/health")({
  server: {
    handlers: {
      GET: async () => {
        try {
          await db.execute(sql`select 1`);
          return Response.json({ ok: true, db: "up" });
        } catch {
          return Response.json({ ok: false, db: "down" }, { status: 503 });
        }
      },
    },
  },
});

On older Start versions the file-based server route API is createServerFileRoute().methods({...}) instead of server.handlers. If the build complains about server, that’s the version difference.

5.4 Test the production build locally

This is the step that catches most deploy failures before they reach Dokploy:

npm run build
PORT=3000 node .output/server/index.mjs

In another terminal:

curl -sI http://localhost:3000 | head -1          # HTTP/1.1 200 OK
curl -s http://localhost:3000/api/health          # {"ok":true,"db":"up"}

If .output/server/index.mjs doesn’t exist, your build didn’t run Nitro: check that the plugin is in vite.config.ts and that npm run build actually ran vite build and not a wrapper script. If the health route returns db: "down" locally, your DATABASE_URL is wrong before Docker is even involved.

5.5 Add a Dockerfile (optional but worth it)

Railpack is fine for a standard Node app. A Dockerfile wins when you need a specific base image, extra system packages, or a build you can reproduce byte for byte on your laptop. It’s also the escape hatch when auto-detection picks the wrong start command and you don’t want to fight it.

# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=build /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

And a .dockerignore so node_modules and secrets stay out of the image:

node_modules
.output
.git
.env
.env.*

Two notes. The runtime stage only copies .output, so drizzle-kit is not in the production image. That’s deliberate, and it’s why step 8 runs migrations from the container’s shell using npx or from a separate step, not from the app’s start command. And USER node works because the app doesn’t write to disk; if you add file uploads, chown a data directory or the write will fail with EACCES.


Step 6: push your code to GitHub

Create an empty repository at github.com/new. Don’t initialize it with a README; you already have files locally. Then:

git add -A
git commit -m "first commit"
git branch -M main
git remote add origin [email protected]:yourusername/tanstack-dokploy-test.git
git push -u origin main

Verify before you leave the terminal:

git log --oneline -1
git status -sb          # should say "## main...origin/main"

If git push fails with Permission denied (publickey), your key isn’t on the account. Test with ssh -T [email protected]. If you’d rather use HTTPS: git remote set-url origin https://github.com/yourusername/tanstack-dokploy-test.git.

The one thing to double-check in the GitHub UI is that .env is not in the repo. Run git ls-files | grep -x '.env'. No output is the correct output.


Step 7: create the app in Dokploy and deploy

7.1 Connect your Git provider

In Dokploy, go to Settings → Git (older builds put it under Providers) and connect GitHub. The GitHub App is the better option than a personal access token: it’s scoped per repository and revocable without breaking anything else.

7.2 Create the application

Go to Applications → Create Application → Git, pick your repository, and set:

  • Name: tanstack-dokploy-app
  • Branch: main
  • Root Directory: / (or ./ depending on your build; if your app lives in a subdirectory, this is where you put it)
  • Build Type: pick one from the tabs below

Dokploy’s menu labels move between releases. If your build doesn’t match the names here, look under General for the build type selector and follow the same logic.

7.3 Set environment variables

Open the Environment tab and add:

DATABASE_URL=postgresql://saas-app:<password>@saas-app-db:5432/saas-app
NODE_ENV=production
PORT=3000

Note the hostname: saas-app-db, the service name from step 2, over the internal network. No public IP, no exposed port.

Verify the app can actually resolve that host before you spend time debugging a build. Open the application’s Terminal tab (it attaches to the running container) and run:

getent hosts saas-app-db
nc -zv saas-app-db 5432

You want an IP address back and succeeded! from nc. getent returning nothing means the two services are on different Docker networks, which usually happens when the database lives in a different Dokploy project. Either move the database into the same project, or fall back to the external connection string with the firewall rule from step 2.

Don't prefix secrets with VITE_

Vite inlines every VITE_* variable into the client bundle. VITE_DATABASE_URL would ship your database password to every visitor in plain text. Keep server secrets unprefixed and read them with process.env on the server only. More on that pattern in Docker Compose secrets.

7.4 Add the domain

In the Domains tab, add app.yourdomain.com and set the port to 3000. Dokploy’s Traefik instance gets the Let’s Encrypt certificate automatically, so there’s no certbot step. Make sure the DNS A record already points at the VPS before you hit deploy, otherwise the ACME challenge fails and you get a certificate error instead of a site.

7.5 Deploy and verify

Click Deploy and watch the Logs tab. On the first build you’re looking for:

  • dependency install completing
  • vite build finishing and writing .output/
  • a container start line (Listening on http://0.0.0.0:3000 or similar)

Then verify from outside:

curl -sI https://app.yourdomain.com | head -1     # HTTP/2 200
curl -s https://app.yourdomain.com/api/health     # {"ok":true,"db":"up"}

A 200 on / and db: "up" on the health route means the whole chain works: DNS, Traefik, TLS, container, network, Postgres. A 502 with a healthy container is nearly always a port mismatch between the container and the Domains tab. That failure mode has its own entry in the troubleshooting section below.


Step 8: run migrations in production

The first deploy has an empty database. Apply the migrations you committed in step 4 from inside the app container, so the connection uses the internal network and you never expose Postgres:

# In the app's Terminal tab in Dokploy
npx drizzle-kit migrate

If npx tries to download the package, drizzle-kit is only in devDependencies and isn’t in the production image. Either add it to dependencies, or use the Dockerfile variant of the image for the deploy, or set a pre-deploy command in Dokploy’s advanced settings if your version exposes one. Any of those works; what you shouldn’t do is open 5432 to your laptop just to run a migration.

Verify:

# In the app Terminal
npx drizzle-kit --version
psql "$DATABASE_URL" -c '\dt'

You should see your tables. If the app then throws relation "todos" does not exist on a request, the migration ran against a different database than the app is using: compare the hostname in $DATABASE_URL inside the container against the service name from step 7.3.

For schema changes later, keep them non-destructive in one deploy: add the column, deploy, backfill, then remove the old one in a follow-up. A migration that drops a column and a deploy that fails is a bad afternoon.


Step 9: auto-deploy, rollback, and backups

Auto-deploy on push

In the application settings, enable Auto Deploy for main. If you want to skip rebuilds for docs-only changes, set Watch Paths to the directories that matter. Then from now on:

git add .
git commit -m "feat: new page"
git push origin main

Dokploy picks up the push and rebuilds. Watch it in the Deployments tab.

Rollback

Every deploy is recorded in the Deployments tab. If a release is bad, that history is your first move: redeploy the previous successful deployment or revert the commit and let Auto Deploy rebuild.

git revert --no-edit <bad-commit-sha>
git push origin main

Reverting code does not revert database migrations. If the bad deploy shipped a destructive migration, rollback means restoring from backup, which is why destructive migrations get their own deploy.

Back up the database

A self-hosted database with no backup is a countdown, not a setup. Dokploy’s database services have a Backup tab where you point them at S3-compatible storage on a schedule. Cloudflare R2 is the cheap option I’d pick. Full walkthrough: Configure Dokploy backups with Cloudflare R2.

Verify the backup by restoring it once, into a scratch database. An untested backup is a hope, not a plan.


How it all works together

Request flow for a page load:

  1. The browser resolves app.yourdomain.com to your VPS IP
  2. Traefik answers on 443, terminates TLS with the Let’s Encrypt certificate
  3. Traefik matches the host rule and forwards to your container on port 3000
  4. TanStack Start renders the route on the server, runs any createServerFn calls, and queries Postgres through Drizzle over the internal Docker network
  5. The response goes back out through Traefik with your SSR HTML inside

Component responsibilities:

  • Dokploy: orchestration, build triggers, domain and TLS config, database lifecycle
  • Railpack or Docker: turns the repo into a container image
  • Docker Swarm: runs and restarts the container
  • Traefik: reverse proxy, HTTPS, host routing
  • PostgreSQL 17: data, on the internal network
  • GitHub: source of truth and deploy trigger

Troubleshooting

502 or 504 after a successful deploy

Cause: port mismatch. The container listens on 3000 but the Domains tab is pointing at a different port (or the default).

Fix: check the container’s start line in the logs for the real port, then set the Domains tab port to match and save. Redeploy after changing it. This is the single most common failure in this stack.

Build killed halfway through (exit code 137)

Cause: out of memory. The OOM killer stops the build, and the logs just end.

Fix: add swap (step 1) and retry. On a 2 GB VPS, consider building the Docker image on your laptop and pushing it, or move to 4 GB. dmesg -T | tail on the host will show the kill if you want proof.

Error: Cannot find module 'x' during build

Cause: a dependency is missing from package.json, or a lockfile mismatch made the install resolve a different tree.

Fix: reproduce locally with a clean install (rm -rf node_modules && npm install), then npm install --save <missing-package> and push. If you have multiple lockfiles (package-lock.json plus pnpm-lock.yaml), delete the ones you don’t use. Builders detect the package manager from lockfiles and will guess wrong if both are present.

Container starts then restarts in a loop

Cause: the process exits immediately, usually from a missing environment variable, a bad DATABASE_URL, or a start command pointing at a file that doesn’t exist.

Fix: open the runtime logs (not the build logs). Error: connect ECONNREFUSED means the database hostname or port is wrong. Cannot find module '/app/.output/server/index.mjs' means the build didn’t produce Nitro output, so revisit step 5.1.

TLS certificate won't issue

Cause: Let’s Encrypt can’t reach your domain. It’s nearly always DNS, or ports 80/443 being blocked.

Fix: dig +short app.yourdomain.com should return your VPS IP. Confirm 80 and 443 are open and nothing else on the box is holding them. Then check the Traefik logs in Dokploy. DNS changes can take a few minutes to propagate, so retry the deploy after a coffee rather than immediately.

Health check says db: down, app looks fine

Cause: the app container and the database aren’t on the same network, or the password has special characters that need URL encoding in the connection string.

Fix: run getent hosts <db-name> from the app Terminal. Empty output means networking; an IP means credentials. For passwords with @, /, or #, percent-encode them (@ becomes %40) or regenerate a password without those characters.

Migrations ran but the table still doesn't exist

Cause: you migrated a different database. This happens when you run db:migrate locally with a shell that still has the dev DATABASE_URL exported.

Fix: run migrations inside the container so it inherits the app’s environment, and check select hash from drizzle.__drizzle_migrations; in the same database the app connects to.


Performance and ops notes

Pool connections. Each container instance should keep a small pool. Postgres defaults to 100 connections total, and a pool of 20 per container gets you in trouble the moment you scale or add a background worker.

// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

export const db = drizzle(pool, { schema });

Stay on the internal network. Connecting through the public IP adds latency and a public attack surface for nothing. If you’re running several apps against one Postgres and hitting connection limits, see multiple PostgreSQL databases in one Docker service.

Cache static assets. Your built assets are content-hashed, so they can be cached forever. A CDN in front is only worth it once you have real traffic, but it’s cheap to add later. Bunny.net is what I use when a site needs edge delivery without Cloudflare’s pricing games.

Monitor it. Dokploy shows CPU and memory per container, which is enough for a start, but it won’t wake you up at 3am. Put Beszel and Uptime Kuma on the same box and point the monitor at /api/health. Thirty minutes of setup, and you’ll know about a broken deploy before your users do.

Watch the disk. Docker images pile up fast. docker system df tells you what’s eating space; docker image prune -a and build cache pruning are routine maintenance on a small VPS.


Hardening checklist

  • Postgres has no external port, so nothing but the app container can reach it
  • Database password is unique to this app and stored in Dokploy’s Environment tab, not in git
  • No VITE_-prefixed secrets, since Vite inlines those into the client bundle
  • SSH is key-only and CrowdSec is running (covered in the Dokploy install guide)
  • The app container runs as a non-root user when you use the Dockerfile path
  • Database backups are scheduled to an S3-compatible bucket and have been restore-tested once
  • npm audit and dependency updates happen on a schedule, not when something breaks

What this actually costs

The VPS is the whole bill most months. A 2 vCPU / 4 GB box at Hetzner runs single-digit euros, and that covers the app, Postgres, Traefik, and your backups’ origin. Add a domain, and optionally a CDN once traffic justifies it. My notes on current Hetzner Cloud pricing after the April 2026 increase have the numbers.

For comparison, Dokploy Cloud starts around $4.50/month per server if you’d rather someone else patch the panel. Same app, same deploy model, one less thing to maintain. Self-hosting the panel is Apache-2.0 and free, which is what’s running in this guide.

Against Vercel, the math is less dramatic than people claim until you have steady traffic. What you actually buy by self-hosting is the absence of a runtime contract: no function duration ceiling, no per-invocation pricing, no platform telling you where your database has to live.


Conclusion

You now have a TanStack Start app running on your own VPS with Postgres 17, generated migrations, HTTPS, and a deploy path you can repeat in one push. The important defaults from this update:

  • Postgres stays on the internal Docker network, never on the public internet
  • Production runs drizzle-kit migrate against committed SQL, never drizzle-kit push
  • The build is pinned by .node-version and explicit scripts, so builder updates don’t surprise you
  • A Dockerfile is there when you need reproducibility over convenience

Test the deploy by pushing a trivial change and watching it go through. A deploy path you’ve only run once isn’t a deploy path.

If you’d rather use a managed database instead of running Postgres yourself, I have a companion guide on building a TanStack Start app with Bunny Database and Drizzle, which swaps Postgres for managed libSQL that idles to zero when unused. And if you’re still deciding on the framework itself, TanStack Start and Convex covers the realtime-backend route.

Getting started with Dokploy

If you haven’t set up Dokploy yet, start here:

Install Dokploy Guide

The installation guide covers everything from VPS setup to security hardening with CrowdSec, ensuring your self-hosted infrastructure is production-ready.

Happy deploying! 🚀