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.

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 -vshould 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
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.
- Open your Dokploy URL (
https://app.yourdomain.com) and go to Databases in the sidebar - New Database → PostgreSQL
- 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
- Version:
- 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.
Railpack auto-detects Node from package.json, installs dependencies, runs build if the script exists, and starts the app from start. With the scripts pinned in step 5.2, there’s nothing else to configure.
- Build Type:
Railpack - Build Command: leave empty (Railpack reads
package.json) - Start Command: leave empty
- Internal Port:
3000
Pin the builder version if you want reproducible builds and don’t want a Railpack release to change your image under you. Dokploy exposes that in the build settings. For how Railpack differs from the older Nixpacks, see Railpack vs Nixpacks.
Nixpacks was Dokploy’s original default and still works. Use it only if you have an existing project configured for it; new projects should be on Railpack.
- Build Type:
Nixpacks - Internal Port:
3000 - Build variable (only if you need to force a Node version):
NIXPACKS_NODE_VERSION=22
Use this when you built the Dockerfile in step 5.5 or you need system packages Railpack won’t install.
- Build Type:
Dockerfile - Dockerfile Path:
./Dockerfile - Docker Context Path:
/(project root) - Internal Port:
3000
This path gives you the same image locally and in production, which makes “it built fine on my machine” stop being a debugging exercise.
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 buildfinishing and writing.output/- a container start line (
Listening on http://0.0.0.0:3000or 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:
- The browser resolves
app.yourdomain.comto your VPS IP - Traefik answers on 443, terminates TLS with the Let’s Encrypt certificate
- Traefik matches the host rule and forwards to your container on port 3000
- TanStack Start renders the route on the server, runs any
createServerFncalls, and queries Postgres through Drizzle over the internal Docker network - 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 auditand 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 migrateagainst committed SQL, neverdrizzle-kit push - The build is pinned by
.node-versionand 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 GuideThe installation guide covers everything from VPS setup to security hardening with CrowdSec, ensuring your self-hosted infrastructure is production-ready.
Happy deploying! 🚀


