Runtime Migration: When Your Build Can't Touch the Database

Build-time migration failed. The CI runner can't reach the production DB. SSH migration failed. The shell has no npx. The only environment that can touch the database is the running app itself.

The pattern: run migrations on first request via middleware.

The Migrated Flag

A module-level boolean gates the migration. First request flips it. Subsequent requests skip.

// src/middleware/migrate.ts
import { migrate } from 'drizzle-orm/mysql2/migrator';
import { db, connection } from '../db';

let migrated = false;

export async function runMigrationOnce() {
  if (migrated) return;
  migrated = true;

  try {
    await migrate(db, { migrationsFolder: './drizzle' });
    console.log('[migrate] done');
  } catch (err) {
    migrated = false; // allow retry on failure
    console.error('[migrate] failed', err);
    throw err;
  }
}

Middleware Hook

// src/middleware/index.ts
import { defineMiddleware } from 'astro:middleware';
import { runMigrationOnce } from './migrate';

export const onRequest = defineMiddleware(async (_ctx, next) => {
  await runMigrationOnce();
  return next();
});

Trade-offs

  • First request takes longer (migration runs synchronously before response)
  • Migration errors surface as 500s on first request, not deploy failures
  • Works in any environment that can reach the DB — no build-time access required

For Hostinger Business shared hosting, this was the only viable path. The migration folder is deployed as part of dist/, and Drizzle reads it at runtime.

One constraint: the drizzle/ migration folder must be included in the rsync step. It's not inside dist/ by default.

- name: Sync to server
  run: |
    rsync -avz --delete \
      dist/ drizzle/ package.json package-lock.json \
      $USER@$HOST:$DEPLOY_PATH/

That's the full pattern. No build-time DB access needed.

Comments 0

Related content coming soon.