Back to VPS & Infra

Migrating Vibe-Coded Apps from SQLite to PostgreSQL: Escaping Database Lock Contention

Learn how to migrate vibe-coded apps from SQLite to PostgreSQL with automated pgloader schema conversion, Docker Compose persistence, and zero-downtime

Migrating Vibe-Coded Apps from SQLite to PostgreSQL: Escaping Database Lock Contention
Image credit: labs.zeroshot.studio

Contents

The Vibe-Coding SQLite Wall: Why Lock Contention Kills Production

When you scaffold a new full-stack application using Cursor, Claude Code, or agent runtime, the AI almost always recommends SQLite. It makes complete sense during development: there are no external daemon dependencies, no network credentials to manage, and your entire database lives in a single local file like dev.db or sqlite.db.

On a single development laptop where you click one button at a time, SQLite performs admirably. But the moment you deploy that vibe-coded project to a production server, the runtime assumptions collapse:

  1. Entire-Database Write Locks: SQLite uses database-level locking during writes. Even with Write-Ahead Logging (WAL) enabled via PRAGMA journal_mode = WAL;, concurrent write transactions from multiple agent workers or web requests must queue sequentially. When concurrent workers write simultaneously, the default timeout expires and throws Error: SQLITE_BUSY: database is locked.
  2. Ephemeral Container Data Loss: If you deploy your vibe-coded app inside a Docker container without explicit volume bindings, any container restart or deployment replaces the image layer and permanently erases your SQLite file.
  3. Weak Type Enforcement: SQLite stores data with dynamic type affinity. Integers, booleans, and ISO timestamps often get stored as raw strings or arbitrary numbers. When your project scales and requires strict relational integrity, SQLite lets corrupt payloads slip through unnoticed.

To build reliable systems, we must graduate from an embedded file to a dedicated relational database server. PostgreSQL provides row-level locking, connection pooling, rigid type constraints, and enterprise backup tooling while maintaining low resource overhead on an affordable Linux VPS.

For deeper background on configuring server baselines before database migration, review our guide to VPS Infrastructure Basics for Vibe Coders.

Architecture Blueprint: Embedded File vs Dedicated Server

The transition from SQLite to PostgreSQL replaces an in-process filesystem driver with a networked, connection-pooled database service:

text
[ Local Vibe-Coded App (SQLite Architecture) ]  Next.js / Node / Python App    └── In-Process SQLite Driver (better-sqlite3 / sqlite3)          └── Single File on Disk (dev.db) ──> Fatal Lock Contention during concurrent writes[ Production Architecture (PostgreSQL Service) ]  App Container / Process    └── Connection Pool (pg / postgres.js / node-postgres)          └── TCP / Unix Socket Connection (Port 5432)                └── PostgreSQL 16 Daemon (Row-Level Locking, WAL Archiving)                      └── Dedicated Named Docker Volume (/var/lib/postgresql/data)

By decoupling storage from application execution, you can restart, update, or scale your web server processes without interrupting database connections or risking database corruption.

Step 1: Provisioning PostgreSQL with Docker Compose

Rather than installing PostgreSQL directly onto the host operating system, isolate the database inside Docker Compose. This ensures your configuration is declarative, reproducible, and easy to migrate between VPS providers.

Create a dedicated directory on your VPS:

Terminalbash
mkdir -p /opt/database && cd /opt/database

Create a docker-compose.yml file with secure resource bounds, health checks, and persistent volumes:

yaml
services:  postgres:    image: postgres:16-alpine    container_name: production-postgres    restart: always    environment:      POSTGRES_DB: app_production      POSTGRES_USER: db_admin      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}    volumes:      - postgres_data:/var/lib/postgresql/data    ports:      # Bind exclusively to localhost or private network to prevent public internet exposure      - "127.0.0.1:5432:5432"    healthcheck:      test: ["CMD-SHELL", "pg_isready -U db_admin -d app_production"]      interval: 10s      timeout: 5s      retries: 5    deploy:      resources:        limits:          memory: 1024Mvolumes:  postgres_data:    name: production_postgres_data

Create the accompanying .env file containing a strong, randomly generated credential:

Terminalbash
# Generate a secure 32-character passwordecho "POSTGRES_PASSWORD=$(openssl rand -base64 24)" > .envchmod 600 .env

Start the PostgreSQL service in detached mode:

Terminalbash
docker compose up -d

Verify that the health check passes and the container is ready:

Terminalbash
docker compose ps

You should see healthy in the status column.

Step 2: Pre-Flight SQLite Data Audit and Type Sanitization

Before executing the migration, inspect your SQLite database file. Because SQLite does not strictly enforce types, AI-generated applications often introduce data inconsistencies such as text values in numeric columns or malformed JSON payloads.

Run a quick integrity check on your SQLite database:

Terminalbash
sqlite3 /path/to/your/app.db "PRAGMA integrity_check;"

The output must return ok. If it returns corruption errors, resolve them before attempting schema migration.

Next, identify date and boolean representations in your SQLite tables:

Terminalbash
sqlite3 /path/to/your/app.db ".schema"

Common pitfalls in vibe-coded SQLite schemas include:

  • Booleans: Frequently stored as INTEGER (0 or 1) or strings ('true' or 'false').
  • Dates and Timestamps: Typically stored as raw strings like '2026-11-03T18:00:00Z' or epoch milliseconds.
  • Auto-Increment Primary Keys: Stored as INTEGER PRIMARY KEY AUTOINCREMENT, which must map to PostgreSQL BIGSERIAL or identity sequences.

Step 3: Automated Migration with pgloader

The most reliable tool for migrating data and schemas from SQLite to PostgreSQL is pgloader. It automatically reads the SQLite table structures, maps data types to native PostgreSQL equivalents, transfers all records, and resets PostgreSQL sequences to match current maximum IDs.

Install pgloader on Ubuntu or Debian:

Terminalbash
sudo apt update && sudo apt install -y pgloader

Create a migration command file named migration.load:

text
LOAD DATABASE     FROM sqlite:///opt/myapp/database.sqlite     INTO postgresql://db_admin@127.0.0.1:5432/app_production WITH include drop, create tables, create indexes, reset sequences,      workers = 4, concurrency = 1 CAST type datetime to timestamptz      drop default drop not null using zero-dates-to-null,      type date to date      drop default drop not null using zero-dates-to-null,      type tinyint to smallint,      type boolean to boolean using integer-to-boolean BEFORE LOAD DO $$ create schema if not exists public; $$;

Export your database password into the environment so pgloader authenticates without embedding plaintext secrets in the command file:

Terminalbash
export PGPASSWORD="your_secure_password"

Execute the migration:

Terminalbash
pgloader migration.load

pgloader displays a clean execution summary showing each table, row counts, transfer speed, and errors:

text
--------------------  ---------  ---------  ---------  ---------               table       read     written     errors    status--------------------  ---------  ---------  ---------  ---------     create, drop ...         8          8          0    ok       fetch meta data        6          6          0    ok--------------------  ---------  ---------  ---------  ---------                users       142        142          0    ok                posts      1024       1024          0    ok             sessions       850        850          0    ok--------------------  ---------  ---------  ---------  ---------  Total import time       0.84s     0.84s          0    ok

If errors appear, review the error log indicated in the console output to pinpoint mismatched data types.

Step 4: Adapting ORMs (Drizzle and Prisma)

After transferring the data, update your application code to connect to PostgreSQL instead of SQLite.

Adapting Drizzle ORM

If your vibe-coded application uses Drizzle, install the PostgreSQL driver:

Terminalbash
npm install pg @types/pgnpm uninstall better-sqlite3 @types/better-sqlite3

Update your database connection configuration:

typescript
// src/db/index.tsimport { 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: 30000,});export const db = drizzle(pool, { schema });

Update your drizzle.config.ts:

typescript
import { defineConfig } from 'drizzle-kit';export default defineConfig({  schema: './src/db/schema.ts',  out: './drizzle',  dialect: 'postgresql',  dbCredentials: {    url: process.env.DATABASE_URL!,  },});

Adapting Prisma ORM

If your project uses Prisma, modify the provider in prisma/schema.prisma:

text
datasource db {  provider = "postgresql"  url      = env("DATABASE_URL")}generator client {  provider = "prisma-client-js"}

Regenerate your Prisma client:

Terminalbash
npx prisma generate

Notice that field types like DateTime and Boolean now map natively to PostgreSQL without string conversions.

Step 5: The Near-Zero Downtime Cutover Workflow

To ensure no records are lost while switching traffic from SQLite to PostgreSQL, follow this disciplined cutover procedure:

  1. Set Application to Maintenance Mode: Temporarily return HTTP 503 or pause background worker daemons.
    Terminalbash
    touch /opt/myapp/.maintenance
  2. Take Final SQLite Snapshot: Copy the exact state of the SQLite database.
    Terminalbash
    sqlite3 /opt/myapp/database.sqlite ".backup '/opt/myapp/backup_pre_migration.sqlite'"
  3. Execute Final pgloader Sync: Run the pgloader migration.load command to bring PostgreSQL 100% up to date.
  4. Update Application Environment: Point DATABASE_URL to PostgreSQL in your application .env:
    Terminalbash
    # Connect using standard PostgreSQL connection parametersDATABASE_URL="postgresql://db_admin@127.0.0.1:5432/app_production?sslmode=disable"PGPASSWORD="your_secure_password"
  5. Restart Application Services: Restart your Node or Python application.
    Terminalbash
    docker compose restart app || systemctl restart myapp
  6. Remove Maintenance Flag: Verify app responsiveness and resume traffic.
    Terminalbash
    rm -f /opt/myapp/.maintenance

The entire sequence typically takes under two minutes for databases under 500 megabytes.

Step 6: Post-Migration Health Checks and Persistence Verification

Once the app is running on PostgreSQL, confirm that transactions, sequence increments, and persistence operate as expected:

  1. Verify Sequence Increments: Create a test record through your app UI or API. Confirm that the new ID increments properly without collision.
  2. Verify Row Counts Match:
    Terminalbash
    docker exec -it production-postgres psql -U db_admin -d app_production -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC;"
  3. Automate Scheduled Backups: Once PostgreSQL is live, never rely solely on Docker volume survival. Configure automated logical dumps and offsite archiving. Follow our complete walkthrough on Zero-Downtime PostgreSQL Backups on Ubuntu to establish hourly snapshots, retention pruning, and offsite storage mirroring.

For additional architectural checklists, explore the ZeroLabs Resources Hub.

FAQ

Why not keep using SQLite with WAL mode on my VPS?

WAL mode allows multiple concurrent readers, but it still enforces a single concurrent writer across the entire database file. If an agent loop writes an audit log while a user submits a form, one process will block. Under sustained load or during automated worker runs, lock timeouts become inevitable.

Will migrating from SQLite to PostgreSQL slow down my small app?

For single-threaded reads, SQLite in-memory file access is microseconds faster than a TCP loopback request. However, PostgreSQL connection pooling amortizes connection setup, and PostgreSQL delivers far superior query planning, caching, and multi-threaded throughput under real-world concurrency.

Does pgloader handle foreign key relationships and unique indexes?

Yes. pgloader reads foreign key constraints, primary keys, and index declarations from the SQLite metadata and recreates them cleanly in PostgreSQL. It also resets auto-increment sequences (SERIAL / IDENTITY) to the current highest existing ID.

How do I revert to SQLite if something breaks?

Because your original SQLite .sqlite file remains untouched on disk during pgloader execution, reverting is immediate: update your application DATABASE_URL back to the SQLite file path, re-install the SQLite driver, and restart your app container.

Share