Integrating PlanetScale Deploy Requests with EF Core

By Jeffrey Needles

August 25, 2026
Engineering
Infrastructure

Integrating PlanetScale Deploy Requests with EF Core

Entity Framework Core has a straightforward migration model: compare the migrations in the application with the rows in __EFMigrationsHistory, then apply anything that is missing. That works well when the application or CI runner is allowed to change the production schema directly.

The difficult part is that a migration which looks small in C# is not necessarily small to a live database. An ALTER TABLE can take a lock that blocks reads or writes, scan or rebuild a large table, or spend a long time populating a new default value. Adding an index, tightening a constraint or changing a column type can compete with normal application traffic and turn an ordinary deployment into an outage. Running the migration successfully in staging tells us the SQL is valid, but it does not tell us how that operation will behave against the size and traffic of production.

PlanetScale gives us a different production workflow. Schema changes happen on a database branch, become a deploy request and go through PlanetScale's review and non-blocking deployment process before reaching production. We wanted to keep EF Core as the source of our migrations without bypassing that workflow.

The solution is a small bridge between EF Core and the PlanetScale CLI. CI asks EF Core which migrations production still needs, creates a temporary PlanetScale branch from production, applies those migrations to the branch and opens a deploy request. CI prepares the change, but a person still reviews and deploys it in PlanetScale.

This post builds on our strongly typed infrastructure with Pulumi and C# setup. Pulumi owns the long-lived database branches and application credentials. A small .NET 10 file-based app and CI own the short-lived migration workflow.

Release Process

Our release proceeds in this order:

Check Staging Migrations
        ↓
Deploy Staging
        ↓
Run Tests
        ↓
Check Prod Migrations
        ↓
Deploy Prod

That order lets the new application and schema run together in staging before the release touches production. Production migrations are checked only after the staging deployment and tests have passed, immediately before the production application deployment.

The sequence is still the same with PlanetScale. What changed is that Check Prod Migrations became much more interesting.

How It Worked Before PlanetScale

Previously, Check Prod Migrations connected to production, asked EF Core for the pending migrations, printed them and failed the release if it found any:

Checking production migrations...
Pending: 20260824205843_urlconfig_missings
Production migrations must be applied before continuing.

That was a useful guard, but it was also a dead end. The pipeline could identify the work without preparing or safely executing it. When the step failed, I would switch to a local checkout, set the environment override that loads production configuration and run the update myself before restarting the release:

use_staging_resources=false dotnet ef database update \
  --project SharedClassesCore/SharedClassesCore.csproj

That command ran directly from my machine against production. I had to make sure I was on the intended branch and commit, that the production override was set correctly, that my local credentials were current and that I had not changed the release while I was preparing it.

The old check prevented us from deploying an application with a missing schema, but it did not make applying that schema safer. The PlanetScale workflow keeps the gate and automates everything between finding a pending migration and asking for the final production approval.

Our Database Layout

We now use one PlanetScale database with two long-lived branches:

Branch Used by Schema changes
prod Production Only through a PlanetScale deploy request
staging Local development and staging EF Core migrations are applied directly

staging is persistent, but it is not a release branch for the database. We never intend to merge it into prod. It accumulates schema changes early and across features/branches so local and staging builds can exercise them, but production deploy requests always start from the current production schema.

That distinction is important. If we created a production deploy request from staging, it could contain old experiments, staging-only changes or schema drift unrelated to the release. Instead, every production migration branch is created from prod and contains only the EF Core migrations that production currently reports as pending.

The complete flow looks like this:

Checked-out master commit
├── EF Core migration files
├── EF Core model snapshot
│
├── staging ──> Database.MigrateAsync() ──> persistent staging branch
│
└── production CI
      ├── compares migrations compiled from master with prod history
      ├── creates temporary branch from prod
      ├── applies only those pending migrations to that branch
      ├── creates a PlanetScale deploy request into prod
      └── stops and waits for manual review/deployment

The checkout at the top of that flow is the source of the desired schema. The production job runs the migration assembly built from the checked-in master commit; it does not infer the next schema from staging or from whichever code happens to be on a runner. EF Core compares the migration IDs compiled into that assembly with the rows currently stored in production's __EFMigrationsHistory. The difference is the ordered pending-migration list used by the rest of the workflow.

The EF Core model snapshot is checked in beside those migrations and records the model used when generating the next migration. It is part of our source-controlled database state, although GetPendingMigrationsAsync determines what is pending from the migration IDs rather than diffing that snapshot against production directly.

PlanetScale must have safe migrations enabled on the production branch. We also enable Automatically copy migration data and select .NET, which tells PlanetScale how to carry __EFMigrationsHistory forward with the schema. PlanetScale documents both the branching and migration-data setting and the deploy request lifecycle.

What Pulumi Owns

Our infrastructure code creates the persistent branches. Simplified, the relevant part looks like this:

var production = new VitessBranch("database-production", new()
{
    Organization = organization,
    Database = database,
    Name = "prod",
    Region = "us-east"
});

var staging = new VitessBranch("database-staging", new()
{
    Organization = production.Organization,
    Database = production.Database,
    Name = "staging",
    ParentBranch = production.Name,
    Region = production.Region
});

Pulumi also creates separate writer and reader passwords for each application environment, then passes their endpoints and credentials into our typed application configuration. That part is covered in more detail in the infrastructure post.

The migration branches are intentionally not Pulumi resources. They exist for one deploy request, get names based on their migration set and can be automatically deleted after deployment. Modeling every one of them in long-lived infrastructure state would make a temporary workflow harder to manage.

Preparing a Production Migration

Running from the checked-out master commit, the migration command connects to production and lets EF Core determine which of those checked-in migrations are pending:

await using var productionDb = await dbFactory.CreateProductionWriter();

var pendingMigrations = (await productionDb.Database
    .GetPendingMigrationsAsync(cancellationToken))
    .ToList();

if (pendingMigrations.Count == 0)
{
    Console.WriteLine("No production migrations are pending.");
    return 0;
}

We use the ordered migration names to create a stable identifier for the work:

var migrationText = string.Join('\n', pendingMigrations);
var hash = Convert.ToHexString(
        SHA256.HashData(Encoding.UTF8.GetBytes(migrationText)))
    .ToLowerInvariant();

var marker = $"hcti-ef-migrations:v1:sha256={hash}";
var branchName = $"ef-prod-{hash[..12]}";

The marker also lets reruns recognize work that is already in progress. CI may run again while a deploy request is waiting for review, while PlanetScale is performing the migration or while its revert window is still open. Before creating anything, our command lists existing deploy requests and looks for the marker in their notes.

var existing = (await planetScale.ListDeployRequests(database))
    .Where(request => request.IntoBranch == productionBranch)
    .Where(request => request.Notes.Contains(marker, StringComparison.Ordinal))
    .OrderByDescending(request => request.Number)
    .FirstOrDefault();

if (existing is not null)
{
    return ReportExistingDeployRequest(existing);
}
Our PlanetScaleCli adapter maps pscale commands and their JSON responses into small typed C# methods such as ListDeployRequests. That keeps the orchestration repeatable in CI and locally; we cover the different authentication paths below.

The deploy-request notes contain both an identity and some context for the reviewer:

hcti-ef-migrations:v1:sha256=<hash of ordered pending migrations>
build=<CI build/run number>
commit=<Git commit SHA>
migrations=<comma-separated EF Core migration names>

Only the hash marker participates in the lookup. The build and commit tell us where a request came from, but neither one changes what database work it represents. A retry from another workflow run or commit still finds the existing request as long as EF Core reports the same ordered set of pending migrations.

If another migration is checked into master before the first request reaches production, the pending-migration list grows and produces a different hash. The next run does not mistake the earlier request for the new desired schema, reuse its branch or tear it down. It prepares a separate branch and deploy request for the expanded migration set, while the earlier request remains available for review or manual cleanup. Our automatic cleanup only deletes a branch created by the current run when that run fails before opening its deploy request; after a successful deployment, PlanetScale handles the branch through --auto-delete-branch.

Create a Temporary Branch and Credential

If there is real work and no matching deploy request, the command uses pscale to create a branch from production:

pscale branch create maindb ef-prod-a1b2c3d4e5f6 \
  --from prod \
  --wait \
  --org our-organization \
  --format json

It then creates a short-lived admin password for that branch. The credential has a one-hour TTL and is deleted in a finally block after EF Core finishes.

pscale password create maindb ef-prod-a1b2c3d4e5f6 migration-builder \
  --role admin \
  --ttl 1h \
  --org our-organization \
  --format json

The JSON response gives the command everything needed to build a temporary EF Core connection string. We disable connection pooling because the credential exists for one short operation, then create the same DbContext the application uses.

Before applying anything, we ask that branch for its pending migrations too:

var branchPending = (await branchDb.Database
    .GetPendingMigrationsAsync(cancellationToken))
    .ToList();

if (!pendingMigrations.SequenceEqual(branchPending, StringComparer.Ordinal))
{
    throw new InvalidOperationException(
        "The migration branch does not have the same EF history as production.");
}

await branchDb.Database.MigrateAsync(cancellationToken);

That check catches a surprisingly dangerous configuration problem. If PlanetScale does not copy .NET migration data into new branches, EF Core can see old migrations as pending and try to run them again. We compare the two lists before modifying the temporary branch.

Open the Deploy Request

After MigrateAsync completes and EF Core confirms that the temporary branch has no pending migrations, the command opens a deploy request back into prod:

pscale deploy-request create maindb ef-prod-a1b2c3d4e5f6 \
  --into prod \
  --notes "hcti-ef-migrations:v1:sha256=..." \
  --disable-auto-apply \
  --auto-delete-branch \
  --org our-organization \
  --format json

The result is a normal open PlanetScale deploy request from the temporary EF branch into prod:

PlanetScale deploy request list showing an open request from a temporary EF migration branch into prod

The build, commit and migration names in those notes give the reviewer a path back from the PlanetScale schema diff to the application change that created it.

PlanetScale deploy request showing its migration hash, build, commit, EF Core migration name and altered table

We disable auto-apply deliberately. PlanetScale can prepare a non-blocking schema change and, with a gated deployment, wait before the final cutover. The CLI supports the full deploy request command set, but our automation stops at creation. Approval, deployment and any gated cutover remain explicit human actions.

A .NET 10 File-Based Migration App

We could put all of this in a shell script or create another console project. Instead, MigrationChecker.cs is a .NET 10 file-based app. It has no dedicated .csproj; the SDK gets its project configuration from directives at the top of the file.

#:project lets the file-based app reference our existing projects:

#:project ../SharedClassesCore/SharedClassesCore.csproj
#:project ../IACModels/IACModels.csproj

using HCTI.Services.DB;
using HCTI.Setup;
using IACModels;
using Microsoft.EntityFrameworkCore;

That gives the migration app the same WriterDBContext, MySQL provider setup, secret models and application service registration used by the rest of HCTI. We do not need to reproduce database configuration in a CI script, and changes to that shared machinery are checked by the compiler.

The rest of the single file contains the pending-migration workflow and a small C# wrapper around pscale. Keeping those together means the EF Core queries, temporary branch connection and CLI response handling all run in one process.

The important part of the wrapper is that it passes arguments directly instead of building a shell command:

var start = new ProcessStartInfo("pscale")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    UseShellExecute = false
};

foreach (var argument in arguments)
{
    start.ArgumentList.Add(argument);
}

start.Environment["PLANETSCALE_SERVICE_TOKEN_ID"] = tokenId;
start.Environment["PLANETSCALE_SERVICE_TOKEN"] = token;

ArgumentList avoids quoting branch names, notes and other values into a command string. The wrapper reads JSON for structured commands and throws on a non-zero exit code. For commands such as password creation, it also marks stdout as secret-bearing so a failure cannot accidentally copy a password into CI logs.

Locally, the same wrapper uses the developer's OAuth-authenticated pscale session after running pscale auth check. In CI, it requires service-token environment variables. The orchestration code stays the same in both places.

CI Setup

During the normal build, we compile the file-based app into the CI artifacts directory. The release step then runs that already-built executable instead of compiling operational code while preparing production.

We use Azure DevOps for HCTI rather than GitHub Actions. The GitHub Actions version is included because it is a more familiar and reusable view of the same setup; the second tab is the shell script from our actual release pipeline.

PlanetScale maintains a setup action for installing its CLI. A GitHub Actions version of the build and migration job could look like this:

name: Deploy production

on:
  workflow_dispatch:
  push:
    branches: [master]

jobs:
  prepare-database:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Install PlanetScale CLI
        uses: planetscale/setup-pscale-action@v1

      - name: Build migration helper
        run: >-
          dotnet build tools/MigrationChecker.cs
          --configuration Release
          --output artifacts/migration-checker

      - name: Prepare production database migration
        env:
          HCTI_ENV_NAME: production
          PLANETSCALE_SERVICE_TOKEN_ID: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_ID }}
          PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
          GITHUB_RUN_NUMBER: ${{ github.run_number }}
          GITHUB_SHA: ${{ github.sha }}
        run: >-
          dotnet artifacts/migration-checker/MigrationChecker.dll
          prepare-production-migration
          --organization "${{ vars.PLANETSCALE_ORGANIZATION }}"
          --database "${{ vars.PLANETSCALE_DATABASE }}"
          --production-branch prod

We create the CI service token in the PlanetScale UI with only the permissions used by this workflow: reading and creating branches, managing a temporary password and reading and creating deploy requests. Its ID and secret are stored in Pulumi ESC, then passed into the CI job as masked secret environment variables. They do not live in the repository or in a developer's local configuration. The GitHub Actions example follows PlanetScale's CLI setup and service-token guidance.

With one pending migration, the runner output shows the whole handoff: find the migration, create the temporary branch, apply it and print the deploy-request URL for a person to review.

CI output showing EF Core applying one migration to a temporary PlanetScale branch and requiring manual approval

Our command returns success when production has no pending migrations. When it creates or finds a matching deploy request, it returns a non-zero result with the PlanetScale URL. That prevents the application deployment from quietly moving ahead while its required schema is still waiting for review.

The next run checks again. Once the deploy request and migration-metadata copy are complete, EF Core reports no pending migrations and the rest of the production deployment can continue.

A Few Failure Cases We Handle

Most of the implementation is cleanup and rerun behavior rather than the happy path:

  • If branch creation succeeds but applying the migration fails, delete the temporary password and branch.
  • If deploy request creation succeeds, keep the branch and let PlanetScale auto-delete it after deployment.
  • If CI reruns, find the request by its migration-set marker instead of creating another one.
  • If the request is still preparing, queued or waiting for cutover, report its URL and keep production blocked.
  • If the request failed, was cancelled or was reverted, require manual review instead of silently replacing it.
  • If EF Core's migration view differs between production and the temporary branch, stop before applying anything.

There is one timing detail to expect: PlanetScale copies migration-table data to production after the revert window closes. During that window, EF Core may still report the migrations as pending. That is why checking for an existing deploy request is part of the normal path, not only retry protection.

Conclusion

Compared with calling Database.MigrateAsync() against production, this workflow adds quite a bit of machinery. Here is what we get for it:

  • Safer deployments. Migrations are applied to an isolated branch first, inspected by PlanetScale, and deployed through its non-blocking schema-change workflow instead of running directly against a live production table. We can also revert if something goes sideways.
  • Better visibility. The deploy request contains the schema diff, affected tables, migration names, build number, and commit SHA. It also gives us one place to see whether a change is waiting for review, running, ready for cutover, or still inside its revert window.
  • No manual migration preparation. CI finds the pending migrations, creates the branch and temporary credential, runs EF Core and opens the deploy request. The only manual step left is the one we want to keep: approving and deploying the production schema change.

Here is what we have to maintain:

  • More moving pieces. EF Core, the pscale CLI, the compiled C# helper, and PlanetScale deploy-request state all participate in one deployment.
  • More CI automation to maintain. Authentication, cleanup, idempotency, and every intermediate deploy-request state need deliberate handling. The happy path is the smallest part of the implementation.
  • EF Core's limitations still exist. A generated migration can still contain an unsafe operation, a poor data migration, or SQL that behaves unexpectedly on MySQL. EF Core is not perfect, but its integration with our models, tooling, and .NET codebase makes those tradeoffs worthwhile for us.

For us, that is worth it. EF Core remains the checked-in history of our schema, CI handles the repetitive preparation, and PlanetScale makes the dangerous part visible and reviewable. It is not a perfect integration, but it is much safer than running production migrations from my laptop.

Loading newsletter signup…

Please wait a moment.

Have a question?

We'd love to hear about what you're building.

support@htmlcsstoimage.com

Get Started

You'll be up and running in 5 minutes.

Grab an API Key

Keep reading

More posts

View all posts
Get Started Now

NO CREDIT CARD NEEDED. 50 FREE IMAGES EVERY MONTH.