Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Database Migrations in CI/CD Pipelines

By Kokil Thapa | Last reviewed: September 2026

Your application code ships through a green pipeline, then production throws a migration error and the deploy stops cold. Database migrations in CI/CD pipelines are the step most teams treat as an afterthought until schema drift, locked tables, or a failed ALTER takes the site offline. On production Laravel applications I maintain with GitLab CI and Deployer 7, migrations are a first-class deploy concern—not a manual SSH task run after the fact. This guide covers what to validate in CI, how to order deploy steps, and how to avoid the failures I see repeatedly on real VPS and shared-hosting setups.

Why do database migrations fail in CI/CD pipelines?

Most migration failures are process problems, not SQL syntax errors. A developer merges a migration locally, CI never touches the database, and the first real run happens on staging—or worse, production.

Common failure modes include the following:

  • Schema drift — staging and production diverge because someone ran hotfix SQL manually.
  • Long-running locks — an ALTER TABLE on a large MySQL table blocks writes during peak traffic.
  • Irreversible migrations — a column drop ships before the deploy that stops reading it.
  • Missing credentials — the pipeline user lacks CREATE, ALTER, or index privileges.
  • Parallel deploys — two release jobs race to run php artisan migrate at once.

On sister legal-tech sites I deploy with the same GitLab CI + Deployer workflow, a single bad migration can block every site on shared infrastructure. That cost is why migrations belong inside the pipeline definition, not in a runbook appendix.

Database Migrations in CI/CD PipelinesDeveloperMigration filesCI PipelineTest DB migrateStagingSmoke testsProductionBackup + migrateVersion-controlled schema = same result in every environmentLaravel migrations table tracks applied batches per release
End-to-end flow for database migrations in CI/CD pipelines from commit through production deploy

The fix starts in CI. Every merge request that adds or changes a migration should prove the schema upgrade works on a clean database and on a database that already has prior migrations applied. That mirrors how teams coordinate migrations without stepping on each other's changes.

How should you run database migrations in a CI/CD pipeline?

A practical pipeline has four migration-related stages: lint, migrate-and-test, staging deploy, and production deploy. Keep them separate. Do not run production migrations from a generic "test" job.

Stage 1 — Validate migration files in CI

On Laravel 13.x with PHP 8.3 or higher, your CI runner should boot the app against a throwaway database service. GitLab CI makes this straightforward with a MySQL 9.7 or PostgreSQL 18 service container.

# .gitlab-ci.yml excerpt
stages:
  - test
  - deploy

variables:
  MYSQL_DATABASE: laravel_test
  MYSQL_ROOT_PASSWORD: secret

test:
  stage: test
  services:
    - mysql:8.4
  script:
    - cp .env.testing .env
    - composer install --no-interaction --prefer-dist
    - php artisan key:generate
    - php artisan migrate --force
    - php artisan test
  only:
    - merge_requests
    - main

The --force flag is required in non-interactive CI environments. Laravel refuses to migrate in production without it. Your CI database is ephemeral, so force is safe there.

Pair this with automated tests in CI so a broken foreign key or missing column fails before anyone merges. For JSON migration payloads or seed fixtures, a quick sanity check with the JSON formatter tool catches typos early.

Stage 2 — Run migrations on staging before production

Staging should use a recent production snapshot or a anonymised copy. Run the same Deployer task you use in production. If staging migration fails, block the production job.

# deploy.php — Deployer 7 task
task('artisan:migrate', function () {
    run('{{bin/php}} {{release_path}}/artisan migrate --force --no-interaction');
});

after('deploy:symlink', 'artisan:migrate');

I commit built frontend assets and run Composer on the deploy runner. The production server has no Node.js. Migrations run after the symlink swap so the new code and new schema align. See the full pattern in deploying Laravel with GitLab CI to a VPS.

CI/CD Pipeline Stages for MigrationsLint / ReviewCI Migrate+ PHPUnit/PestStagingProductionBackup firstProduction deploy sequence1. Enable maintenance (if needed)2. Database backup snapshot3. Symlink swap + php artisan migrate --force4. PHP-FPM reload + smoke test
Recommended CI/CD pipeline stages that isolate migration validation from production execution

Stage 3 — Protect credentials

Database URLs belong in CI/CD variables, never in the repository. Rotate credentials separately for CI, staging, and production. Read handling secrets in CI/CD pipelines safely and secrets management best practices before wiring DB_PASSWORD into YAML.

The official Laravel migrations documentation covers file structure and rollback methods. Your pipeline should enforce what the docs assume: migrations are forward-only in production unless you have a tested rollback plan.

What is the safest order for deploying code and running migrations?

Deploy order depends on whether the migration is backward-compatible with the currently running code. Get this wrong and users hit 500 errors between the migration and the code swap—or vice versa.

Migration typeExampleSafe orderRisk if reversed
Backward-compatible expandAdd nullable columnMigrate first, then deploy codeLow — old code ignores new column
Contract / destructiveDrop column, rename tableDeploy code first, migrate laterHigh — old code breaks on missing column
Data backfill requiredSplit name into first/lastMulti-phase across releasesPartial data, failed jobs
Index-only changeAdd index onlineEither order; watch lock timeSlow queries during build

The expand/contract pattern is the default for zero-downtime work. Release one adds a new column. Release two writes to both. Release three drops the old column. I use this on booking systems like Adventure Third Pole Trek where downtime during peak season is not acceptable.

Migration Deploy Order DecisionSchema change type?Expand (add)Migrate then deployContract (drop)Deploy then migrateMulti-phaseSplit releasesRule: running code must tolerate current schema at all timesDocument order in deploy.php or pipeline YAML comments
Decision tree for ordering database migrations relative to application deploys in CI/CD

Document the expected order in your deploy recipe. Future you—or the next contractor—should not guess from migration filenames alone. Follow Laravel migration and seeding best practices for naming, idempotency, and seed separation.

How do you handle database migrations without downtime?

True zero downtime needs both application design and database technique. A pipeline green light does not magically make a table rewrite instant on a 40 GB MySQL table.

Use online-friendly DDL where the engine allows

MySQL 8.4 LTS and MySQL 9.7 support ALGORITHM=INPLACE and LOCK=NONE for many operations. PostgreSQL 18 often handles additive changes with lighter locking. Always check the execution plan on a staging copy with production-like row counts.

/* Laravel migration — additive column with index */
public function up(): void
{
    Schema::table('bookings', function (Blueprint $table) {
        $table->string('reference_code', 32)
              ->nullable()
              ->after('id');
        $table->index('reference_code');
    });
}

Heavy index builds may still throttle I/O. Schedule them off-peak or use a dedicated maintenance window. For deeper patterns, read zero-downtime Laravel database migrations and migrations at scale.

Expand / Contract Migration PatternRelease 1: ExpandAdd new column (nullable)Old code still runsRelease 2: Migrate dataDual-write both columnsBackfill job in queueRelease 3: ContractDrop old columnRemove dual-write codeEach release passes CI migrate + full test suitePipeline gates prevent skipping a phaseRollback = redeploy previous release (schema stays expanded)
Three-release expand and contract pattern for zero-downtime schema changes in CI/CD

Serialize migration execution

Only one deploy should migrate at a time. In GitLab CI, use resource_group: production on the deploy job. Deployer’s built-in lock file also prevents concurrent releases on the same host.

deploy_production:
  stage: deploy
  resource_group: production
  script:
    - dep deploy production -o migrate=1
  when: manual
  only:
    - main

After symlink swap, reload PHP-FPM so opcache serves the new code. I’ve debugged “migration ran but app still errors” cases that were simply stale opcache, not schema issues.

How do you test database migrations before production deploy?

CI testing should cover three scenarios every time a migration file changes.

  1. Fresh installmigrate --force on an empty database proves new projects boot correctly.
  2. Upgrade path — load a schema dump from last release, then migrate forward one batch.
  3. Application tests — run PHPUnit or Pest against the post-migration schema.

For the upgrade path, store a sanitized SQL dump as a CI artefact or rebuild it nightly from staging. The goal is catching "works on my machine" drift before merge.

Add a migration smoke job that fails fast on destructive operations in production-bound branches:

php artisan migrate --pretend --force 2>&1 | tee migrate-dry-run.log
if grep -E 'dropColumn|dropForeign|renameColumn' migrate-dry-run.log; then
  echo "Destructive migration detected — require manual review"
  exit 1
fi

This grep is crude but effective for small teams. Larger teams use custom Artisan commands or migration linters. Pair schema work with indexing strategy reviews so new columns do not ship without supporting indexes.

Production Migration Failure PointsNo backupIrreversible DDLParallel deploysDouble migrate raceLock timeoutPeak traffic ALTERMitigations baked into CI/CD pipelinePre-deploy dumpresource_group lockExpand / contract
Common production migration failure points and CI/CD mitigations for database migrations in CI/CD pipelines

Rollback reality check

php artisan migrate:rollback is not a production disaster-recovery plan. Rollback methods are often empty or destructive. Your real rollback is redeploying the previous release directory via dep rollback and restoring a database backup if the migration was not backward-compatible.

Take a logical backup immediately before production migration. On Ubuntu servers I maintain, a nightly cron plus a pre-deploy dump covers most cases. Budget roughly Rs 3,000–8,000/month (~USD 22–60) for managed backup storage if you are not self-hosting. Linux system administration and ongoing support contracts should include backup verification—not just creation.

The GitLab CI/CD documentation explains resource_group, environments, and manual gates. The Deployer 7 documentation covers zero-downtime releases and shared paths for .env and storage/.

What tools and conventions work best for PHP/Laravel teams?

Most PHP teams I work with standardise on Composer 2.10, Laravel’s built-in migration runner, and one deploy tool. Symfony 8.1 projects use Doctrine migrations with the same CI principles.

Conventions that reduce pipeline incidents:

  • One migration per logical change — easier to review and revert.
  • Never edit a migration after it merged to main — add a new file instead.
  • Keep seeders out of production deploy jobs unless explicitly idempotent.
  • Store schema dumps for CI upgrade tests in database/schema/.
  • Require peer review on any migration touching payments, auth, or PII tables.

For client portals like Mijar Law Associates, document tables hold sensitive uploads. A migration that loosens constraints or drops audit columns needs legal and ops sign-off—not just a developer approval.

Compare pipeline runners in GitHub Actions vs GitLab CI for 2026 and the PHP-focused walkthrough in GitLab CI/CD for PHP projects. Small teams should read CI/CD best practices for small teams before over-building five environments.

If you need professional pipeline design beyond blog copy, testing and optimization services and custom software development cover audit through implementation. See shipped work on the portfolio and background on about me.

Key Takeaways

  • Run php artisan migrate --force against a real database service in every CI pipeline that touches migration files.
  • Order deploys by compatibility: expand before code, contract after code, multi-phase for data backfills.
  • Serialize production migrations with resource_group or deploy locks so two releases never race.
  • Take a backup immediately before production migration; treat migrate:rollback as a dev convenience, not DR.
  • Test fresh install and upgrade-from-last-release paths, not only greenfield schemas.
  • Document expand/contract phases in the pipeline so no release skips a required step.

People Also Ask

Should database migrations run before or after deploying application code?

It depends on compatibility. Additive changes (new nullable columns, new tables) should migrate before the new code deploys. Destructive changes (dropping columns, renaming fields) require the new code to ship first. When unsure, default to expand/contract across multiple releases.

Can you automate Laravel migrations in GitHub Actions or GitLab CI?

Yes. Both support database service containers for CI validation and SSH or agent-based deploy steps for staging and production. Use protected variables for credentials, manual approval gates for production, and --force in non-interactive environments.

How do you prevent two deploys from running migrations simultaneously?

Use GitLab CI resource_group, GitHub Actions concurrency groups, or Deployer’s deploy lock. Only one production migration should execute at a time. Parallel app servers can share one migration runner during deploy.

What happens if a migration fails mid-deploy?

Laravel records batches in the migrations table. A failed migration leaves the batch incomplete and may block later runs. Fix forward with a corrective migration or restore from backup if the change was destructive. Never leave production in a half-migrated state overnight.

Ship schema changes with the same discipline as application code

Database migrations in CI/CD pipelines earn trust when they are tested on every merge, gated before production, backed up on every deploy, and ordered against running code. Treat manual phpMyAdmin edits as technical debt. Version everything. Automate validation. Plan rollbacks as redeploy plus restore—not wishful rollback methods.

If your Laravel or PHP app still runs migrations by hand over SSH, the pipeline is incomplete. Contact us for a CI/CD and migration audit, or start with the step-by-step GitLab CI pipeline for Laravel and extend it with the patterns above.

Frequently Asked Questions

Most failures are process problems, not bad SQL. CI often never runs migrations against a real database, so the first execution happens on staging or production. Common causes include schema drift from manual hotfix SQL, long-running ALTER TABLE locks during peak traffic, irreversible column drops shipped before code stops reading them, pipeline users missing CREATE or ALTER privileges, and two deploy jobs racing php artisan migrate at once. On shared VPS infrastructure, one bad migration can block multiple sites until fixed.

Split migration work into four stages: lint, migrate-and-test, staging deploy, and production deploy. In GitLab CI on Laravel 13.x with PHP 8.3 or higher, boot the app against a throwaway MySQL 9.7 or PostgreSQL 18 service container, run composer install, then php artisan migrate --force followed by your test suite on every merge request. Staging should mirror production using the same Deployer 7 migrate task. Block the production job if staging migration fails. Never run production migrations from a generic test job.

It depends on compatibility. Additive changes like new nullable columns or tables should migrate before new code deploys. Destructive changes like dropping or renaming columns require new code to ship first. When unsure, use expand/contract across multiple releases.

Order depends on whether the migration is backward-compatible with currently running code. Expand changes—adding nullable columns or new tables—should migrate first, then deploy code. Contract changes—dropping columns or renaming tables—need code deployed first, then migration. Data backfills often require multi-phase releases. Index-only changes can run in either order but watch lock time on large tables. Document the expected order in your Deployer 7 recipe so future deploys do not rely on guessing from migration filenames alone.

Yes. Both support database service containers for CI validation and SSH or agent-based deploy steps for staging and production. Use protected variables for credentials, manual approval gates for production, and --force in non-interactive environments.

Every migration file change should pass three CI scenarios. First, migrate --force on an empty database to prove fresh installs boot correctly. Second, load a sanitized schema dump from the last release and migrate forward to catch upgrade-path breakage. Third, run PHPUnit or Pest against the post-migration schema. Store schema dumps in database/schema/ or rebuild nightly from staging. Add a migrate --pretend smoke job that flags destructive operations like dropColumn or renameColumn before they reach production-bound branches.

A green pipeline does not make a 40 GB table rewrite instant. Use the expand/contract pattern: release one adds a column, release two writes to both, release three drops the old column. MySQL 8.4 LTS and MySQL 9.7 support ALGORITHM=INPLACE and LOCK=NONE for many operations; PostgreSQL 18 often handles additive changes with lighter locking. Test execution plans on staging with production-like row counts. Schedule heavy index builds off-peak. Serialize migration execution so only one deploy migrates at a time using GitLab CI resource_group or Deployer locks.

Use GitLab CI resource_group, GitHub Actions concurrency groups, or Deployer's deploy lock. Only one production migration should execute at a time. Parallel app servers can share one migration runner during deploy.

Laravel records batches in the migrations table. A failed migration leaves the batch incomplete and may block later runs. Fix forward with a corrective migration or restore from backup if the change was destructive. Never leave production in a half-migrated state overnight. Your real rollback is redeploying the previous release via dep rollback and restoring a database backup if the migration was not backward-compatible—not relying on empty migrate:rollback methods.

No. Rollback methods are often empty or destructive, and migrate:rollback is a development convenience—not a disaster-recovery plan. Production recovery means redeploying the previous release directory through Deployer 7 using dep rollback, then restoring a logical database backup if the schema change was not backward-compatible. Take a backup immediately before every production migration. On Ubuntu servers I maintain, a nightly cron plus a pre-deploy dump covers most recovery scenarios without depending on reversible migration code.

Budget roughly Rs 3,000–8,000 per month (~USD 22–60) for managed backup storage if you are not self-hosting dumps on your own VPS. That covers the logical backups you need before each production migration run. Backup creation alone is not enough—ongoing support contracts should include backup verification and tested restore procedures. A failed migration on a booking system during peak season costs far more than reliable storage.

Database URLs and passwords belong in CI/CD variables, never in the repository. Rotate credentials separately for CI, staging, and production environments. The CI runner needs enough privilege to CREATE and ALTER tables during migrate-and-test jobs, but production deploy credentials should be scoped narrowly and protected behind manual approval gates. Read your platform's secrets management documentation before wiring DB_PASSWORD into YAML. Treating credentials as versioned code is one of the fastest ways to expose production databases.

Standardise on Composer 2.10, Laravel's built-in migration runner, and one deploy tool like Deployer 7. Ship one migration per logical change so reviews and reverts stay simple. Never edit a migration after it merged to main—add a new file instead. Keep seeders out of production deploy jobs unless explicitly idempotent. Require peer review on migrations touching payments, auth, or PII tables. On client portals with sensitive document uploads, migrations that loosen constraints or drop audit columns need legal and ops sign-off, not just developer approval.

A common cause is stale PHP opcache, not schema issues. On Deployer 7 workflows I use, migrations run after the symlink swap so new code and new schema align—but PHP-FPM must be reloaded afterward or workers continue serving old bytecode. I've debugged several "migration ran but app still errors" cases that were simply opcache, not missing columns. Also verify the migration batch completed in the migrations table and that no parallel deploy raced the schema change on another release job.

Add a CI smoke job that runs php artisan migrate --pretend --force and pipes output through a grep check for dropColumn, dropForeign, or renameColumn. If matched, fail the pipeline and require manual review. This grep approach is crude but effective for small teams running GitLab CI. Larger teams may prefer custom Artisan commands or migration linters. Pair this with staging deploys using a recent production snapshot so destructive changes surface before the manual production gate, and always take a logical backup immediately before the production migrate step runs.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: