MongoDB Migrations in C# from GitHub Actions
A couple of months ago I wrote about the MongoDB decimal change that quietly broke our migration. The short version: a driver upgrade left us with money stored as strings in some documents and as Decimal128 in others, and the migration we wrote to tidy it up fell over on the very first string.
What I didn’t dwell on at the time was where that migration ran, and how little it told us. It ran as a background job inside our API. When it failed, it failed politely. It logged an error, got retried, failed again on the same document, and made no progress whatsoever. Meanwhile, the deploy that shipped it had sailed through with a lovely green tick, and nothing anywhere said how many orders it had actually changed. (None. It had changed none.)
That’s been nagging at me ever since, because it isn’t really a decimal problem. It’s a “where do migrations live” problem, and running them inside the app gives you four of them:
- The new code is already taking requests before its data changes are in place.
- When a migration fails, nothing anyone is watching goes red.
- Every instance of the app has a go at running it.
- Nothing records what it actually did.
What I actually want is easy to say: run the migrations once per deploy, before any of the new code runs, fail the build if they fail, and keep a record of exactly what they did. Something like this:
build → test → push image → migrate → deploy
If migrate goes red, nothing after it happens, and the old code carries on quite happily against the old data.
Let’s build that with GitHub Actions, an app running on AWS Fargate, and a database on MongoDB Atlas.
Meet Kevsoft.MigrateMongo
Full disclosure: the migration tool in this post is one I wrote. I wanted migrations that are:
- written in C# with the normal MongoDB driver;
- run in a transaction when the cluster supports it;
- guarded by a lock, so two runs can’t trip over each other;
- checksummed, so nobody quietly edits one that’s already been applied;
- able to tell you exactly what they did.
It’s called Kevsoft.MigrateMongo, and the usage guide covers everything I’m about to skim over.
Migrations live in a small console project that you own. Here’s the second migration from the decimal post, the one that fills in each order line’s LineTotal:
[Migration("20261022090000", "backfill-line-totals")]
public sealed class BackfillLineTotals : IMigration
{
public async Task UpAsync(MigrationContext context, CancellationToken cancellationToken)
{
var orders = context.Track(context.Database.GetCollection<BsonDocument>("orders"), "backfilled");
await orders.UpdateManyAsync(
Builders<BsonDocument>.Filter.Exists("Lines.LineTotal", false),
LineTotalFromPriceAndQuantity, // the $map pipeline from the decimal post
cancellationToken: cancellationToken);
var empty = await orders.Collection.CountDocumentsAsync(
context.Session, Builders<BsonDocument>.Filter.Size("Lines", 0), cancellationToken: cancellationToken);
context.Results.Set("emptyOrders", empty);
if (empty > 0)
{
context.Results.Note($"{empty} orders have no lines.", NoteLevel.Warning);
}
}
// DownAsync unsets LineTotal again
}
context.Track wraps the collection. The update runs exactly as it normally would, inside the migration’s transaction with the session passed for you, but it also gets written down:
- what kind of operation it was;
- its filter and update;
- how many documents it matched and modified;
- how long it took.
The "backfilled" label adds those counts to the migration’s results. context.Results holds anything else worth keeping, like a count or a warning.
A migrate-mongo.yaml points the project at a database:
project: src/MyApp.Migrations
mongodb:
url: ${MONGODB_URL}
databaseName: shop
And running it looks like this:
$ dotnet migrate-mongo up
Built src/MyApp.Migrations/MyApp.Migrations.csproj (3.3s)
Runner: Kevsoft.MigrateMongo 1.0.0 · MongoDB.Driver 3.13.0 · .NET 10.0.14 · protocol 1
Acquired lock (run 05d63952bd8541e29d4890bdca002726).
▶ 20261022090000 backfill-line-totals
warning: 3 orders have no lines.
✔ 20261022090000 backfill-line-totals (0.21s, transaction)
backfilled matched 12480 · modified 12477 · upserted 0
emptyOrders 3
1 operation(s) recorded – migrate-mongo show 20261022090000
Applied 1 migration(s) in batch 1.
Look at those counts. They aren’t a log line somebody remembered to write. They’re what MongoDB reported back, and they’ve already caught something: 12,480 orders matched, but only 12,477 were modified. The missing three are the empty orders. Mapping over an empty array changes nothing, so MongoDB doesn’t count them as modified. It’s a harmless difference this time, but it’s exactly the kind of detail you’d never see if all you had was “migration complete”. Hang on to that output, because we’ll see it again in a GitHub Actions log, and once more after that.
Behind the scenes the tool keeps three collections:
_migrations, with one document per migration, including its results and operations;_migrations.history, an append-only log;_migrations.lock, which only exists while a run is in progress. It’s what stops two deploys applying the same migration twice.
Two more things matter for the rest of this post. First, bundles. migrate-mongo bundle -r linux-x64 publishes your migrations project as a single self-contained executable, so the machine that runs it doesn’t need .NET at all. Ours comes out at about 80 MB. Second, exit codes, because the pipeline leans on them:
| Exit code | Meaning |
|---|---|
0 |
Success |
1 |
A migration failed |
3 |
Timed out waiting for another run’s lock |
4 |
Validation failed: an applied migration was edited or removed |
5 |
The tool is older than the migrations it’s running |
The catch: CI can’t see the database
Here’s where my lovely diagram hits reality. Our cluster lives on MongoDB Atlas, and it’s locked down twice over:
- The network. It’s only reachable over private networking from inside our AWS VPC (VPC peering or PrivateLink both work). Its host names resolve to private addresses, and nothing on the public internet can even open a connection.
- Identity. There are no database passwords at all. Each app signs in with its own AWS IAM role, and each role is a database user that can only reach that app’s own data.
Getting onto the network isn’t enough. You have to be one of the apps.
A GitHub-hosted runner fails on both counts. It can look the host names up all day long, but it can’t reach them, and it has no role the database recognises. And honestly, that’s the right way round: nobody should be poking holes in either so the build server can get in. So the question isn’t how to get the database out to CI. It’s how to get the migrations into the network as an app of their own.
Ways round it
The first idea everyone has is to skip CI altogether and run the migrations when the app starts. The tool can even do that for you with RunOnStartup() from Kevsoft.MigrateMongo.Hosting, and for plenty of apps that’s fine. But look closely and it’s the decimal problem again: a failed migration gives you a failed rollout somewhere, not a red step in the build.
So, back to CI. Here’s what we weighed up:
| Option | Verdict |
|---|---|
| Allow-list the runners in Atlas | GitHub publishes over 7,000 IP ranges for Actions, shared with everyone else. Static IPs need larger runners on GitHub Enterprise Cloud. |
| Bastion host and an SSH tunnel | Works, and the tool can open the tunnel itself, but it’s a box to patch and a private key sitting in your secrets. |
| Self-hosted runner in the VPC | Great if lots of jobs need the network; a fleet to look after if it’s just this. |
| A one-off task in the VPC | Same network path as the apps, an IAM role of its own, nothing opened inbound, nothing secret in CI. |
I’ll admit we really did go looking for a jump box first.
So we went with the one-off task. The app already runs on ECS Fargate, which proves both the network path to Atlas and the IAM authentication work. The migrations ride on exactly the same thing, as one more app with a role of its own. CI’s only job is to say “run this, and tell me how it went”.
Packaging the migrations
The migrations project stands on its own, outside the app’s solution, so building the app never depends on it. To get it into a container, bundle it and build an image on top of the tool’s own -bundle image, which is runtime-deps plus the migrate-mongo tool:
FROM ghcr.io/kevbite/migrate-mongo:1.0.0-bundle
COPY bundle/ /src/
USER $APP_UID
CMD ["up", "--runner", "./MyApp.Migrations"]
The bundle could run on its own, but having the tool drive it with --runner means every run starts with that Runner: line, so the log records exactly which versions did the work. Two things to remember:
- Keep the image tag on the same version as your packages. A tool that’s older than the runner it’s driving stops with exit code
5. - The image’s entrypoint is now
migrate-mongo. So for an ad-hoc command likeshow, you need to repeat the--runnerbit. More onshowshortly.
Just like the apps, the migrations sign in without a password. Reference Kevsoft.MigrateMongo.Aws from the migrations project and ask for AWS IAM auth in the connection string:
mongodb+srv://shop-pri.ab1cd.mongodb.net/?authSource=%24external&authMechanism=MONGODB-AWS
The driver picks up the ECS task role’s credentials and signs in with those. One gotcha: pin MongoDB.Driver.Authentication.AWS to the same version as MongoDB.Driver, otherwise the two can end up on different versions.
The task, its role and its logs
Now for the infrastructure. I’m using the CDK in C#, so everything stays in one language. Here’s the migrations stack, trimmed down:
public class MigrationsStack : Stack
{
public MigrationsStack(Construct scope, string id, MigrationsStackProps props) : base(scope, id, props)
{
var vpc = Vpc.FromLookup(this, "Vpc", new VpcLookupOptions { VpcId = props.VpcId });
var cluster = new Cluster(this, "Cluster", new ClusterProps { Vpc = vpc });
var taskRole = new Role(this, "TaskRole", new RoleProps
{
RoleName = $"myapp-migrations-{props.EnvironmentName}",
AssumedBy = new ServicePrincipal("ecs-tasks.amazonaws.com"),
});
var taskDefinition = new FargateTaskDefinition(this, "Task", new FargateTaskDefinitionProps
{
Family = $"myapp-migrations-{props.EnvironmentName}",
Cpu = 256,
MemoryLimitMiB = 512,
TaskRole = taskRole,
});
var logGroup = new LogGroup(this, "Logs", new LogGroupProps { Retention = RetentionDays.ONE_MONTH });
var repository = Repository.FromRepositoryName(this, "Repository", "myapp-migrations");
taskDefinition.AddContainer("Migrations", new ContainerDefinitionOptions
{
ContainerName = "Migrations",
Image = ContainerImage.FromEcrRepository(repository, (string)Node.TryGetContext("imageTag")),
Logging = LogDrivers.AwsLogs(new AwsLogDriverProps { LogGroup = logGroup, StreamPrefix = "migrations" }),
Environment = new Dictionary<string, string> { ["MONGODB_URL"] = props.MongoDbUrl },
});
var securityGroup = new SecurityGroup(this, "SecurityGroup", new SecurityGroupProps { Vpc = vpc });
new CfnOutput(this, "ClusterName", new CfnOutputProps { Value = cluster.ClusterName });
new CfnOutput(this, "TaskDefinitionArn", new CfnOutputProps { Value = taskDefinition.TaskDefinitionArn });
// ...and the subnets, security group, log group and container name
}
}
A few choices in there are deliberate:
- It has its own cluster, because in a brand-new environment the migrations run before the app’s cluster exists.
- The image is pinned to the commit.
imageTagcomes in from the workflow as the commit SHA, so the task runs exactly the migrations that were just tested. - It’s plain Fargate, not Spot. Spot can be taken away, and losing a task halfway through a migration that isn’t in a transaction leaves it stuck as
applying. - The role has a fixed name, because Atlas names its database user after it.
Here’s the Atlas side, as one Atlas CLI command. Like every other app’s user, it can only touch its own database:
atlas dbusers create \
--username arn:aws:iam::123456789012:role/myapp-migrations-production \
--awsIAMType ROLE \
--role readWrite@shop
You don’t need to say anything about the $external auth database: the CLI sets it for IAM users itself, and it won’t let you give them a password either. If your migrations ever change validators with collMod, add dbAdmin as well.
Letting GitHub Actions in, and no further
GitHub Actions gets into AWS with OpenID Connect, so there are no access keys in your secrets. The workflow asks for a token like this:
permissions:
id-token: write
contents: read
That second line matters more than it looks. As soon as you set any permission, all the others drop to none. Without contents: read, your checkout step suddenly can’t see your own repository.
On the AWS side, the deploy role trusts tokens from your repository’s production environment:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-app:environment:production"
}
}
Watch that sub format. Repositories created before 15 July 2026 use the repo:OWNER/REPO:environment:NAME form above. Newer repositories default to an immutable format that adds the owner’s and repository’s ids, and GitHub only documents what that looks like for branches. So rather than guess, drop this step into the deploy job once and copy what it prints:
- name: Show the OIDC subject
run: |
curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" \
| jq -r '.value | split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson | .sub'
Those gsubs matter. Tokens use URL-safe base64, which the snippet in GitHub’s archived OIDC debugger can choke on, because it pipes the token straight into base64 -d.
Running the migrations needs very little:
ecs:RunTaskon the migrations task definition;ecs:DescribeTasks;iam:PassRoleon just the task’s two roles;logs:GetLogEventson its log group;cloudformation:DescribeStacks, to read the stack’s outputs.
cdk deploy also wants to assume the CDK bootstrap roles. If it can’t, it says current credentials could not be used to assume … and carries on with the workflow’s own credentials, which then need the deploy permissions themselves. Either way, CI can start the migrations but never holds a database credential at all.
Running it and watching it
The first version of the script that runs the task was the obvious one:
task=$(aws ecs run-task --cluster "$cluster" --task-definition "$task_definition" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$subnets],securityGroups=[$security_group]}" \
--query 'tasks[0].taskArn' --output text)
aws ecs wait tasks-stopped --cluster "$cluster" --tasks "$task"
aws logs tail "$log_group" --log-stream-name-prefix "migrations/Migrations/${task##*/}"
It works, right up until it doesn’t, for three reasons:
aws ecs wait tasks-stoppedgives up after 10 minutes (100 checks, 6 seconds apart) with exit code 255. Any longer migration fails the build while it’s still running perfectly happily.- You see nothing at all until the very end.
- CloudWatch can receive the last few lines just after the task stops, so the tail can miss the most interesting bit: the error.
So the second version does three things differently:
- It checks
failures[0].reason, because a task that never started doesn’t give you an ARN. - It prints new log lines as they arrive.
- When the task stops, it keeps reading for a while longer.
Here it is in full:
#!/usr/bin/env bash
# Runs the migrations task from a migrations stack to completion, printing its output as it goes,
# and fails if the migrations did.
#
# scripts/run-migrations.sh <stack name>
set -euo pipefail
stack=$1
output() {
aws cloudformation describe-stacks --stack-name "$stack" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue | [0]" --output text
}
cluster=$(output ClusterName)
task_definition=$(output TaskDefinitionArn)
container=$(output ContainerName)
subnets=$(output SubnetIds)
security_group=$(output SecurityGroupId)
log_group=$(output LogGroupName)
task=$(aws ecs run-task \
--cluster "$cluster" \
--task-definition "$task_definition" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$subnets],securityGroups=[$security_group],assignPublicIp=DISABLED}" \
--started-by "github-${GITHUB_RUN_ID:-manual}" \
--query 'tasks[0].taskArn || failures[0].reason' --output text)
if [[ "$task" != arn:aws:ecs:* ]]; then
echo "The migrations task didn't start: $task"
exit 1
fi
echo "Started $task"
log_stream="migrations/$container/${task##*/}"
log_token=""
log_errors=$(mktemp)
log_error_reported=""
trap 'rm -f "$log_errors"' EXIT
# Prints the task's log lines that haven't been printed yet, and fails if there weren't any.
# Reads at most 10 pages at a time, so a busy stream can't hold up the status checks.
print_new_logs() {
local args page next printed=1 pages=0
while [ "$pages" -lt 10 ]; do
args=(--log-group-name "$log_group" --log-stream-name "$log_stream" --output json)
if [ -n "$log_token" ]; then args+=(--next-token "$log_token"); else args+=(--start-from-head); fi
if ! page=$(aws logs get-log-events "${args[@]}" 2>"$log_errors"); then
# The stream only exists once the container has started.
if ! grep -q ResourceNotFoundException "$log_errors" && [ -z "$log_error_reported" ]; then
echo "Couldn't read the task's logs: $(cat "$log_errors")"
log_error_reported=1
fi
return 1
fi
jq -r '.events[] | "\(.timestamp / 1000 | floor | todate) \(.message)"' <<<"$page"
if [ "$(jq '.events | length' <<<"$page")" -gt 0 ]; then printed=0; fi
# The end of the stream, for now, is the page that hands back the token it was asked for.
next=$(jq -r '.nextForwardToken' <<<"$page")
if [ "$next" = "$log_token" ]; then return $printed; fi
log_token=$next
pages=$((pages + 1))
done
return $printed
}
last_status=""
while true; do
status=$(aws ecs describe-tasks --cluster "$cluster" --tasks "$task" --query 'tasks[0].lastStatus' --output text)
if [ "$status" != "$last_status" ]; then
echo "Migrations task is $status"
last_status=$status
fi
[ "$status" = "STOPPED" ] && break
print_new_logs || true
sleep 5
done
# CloudWatch can receive the last lines after the task has stopped, so keep reading until it's been quiet for a while.
quiet_polls=0
while [ "$quiet_polls" -lt 3 ]; do
sleep 5
if print_new_logs; then quiet_polls=0; else quiet_polls=$((quiet_polls + 1)); fi
done
exit_code=$(aws ecs describe-tasks --cluster "$cluster" --tasks "$task" \
--query "tasks[0].containers[?name=='$container'] | [0].exitCode" --output text)
if [ "$exit_code" != "0" ]; then
reason=$(aws ecs describe-tasks --cluster "$cluster" --tasks "$task" \
--query "[tasks[0].stoppedReason, tasks[0].containers[?name=='$container'] | [0].reason]" --output text)
case "$exit_code" in
3) meaning="another run held the migrations lock" ;;
4) meaning="validation failed: an applied migration was edited or removed" ;;
5) meaning="the migrate-mongo image is older than the Kevsoft.MigrateMongo packages" ;;
None) meaning="the container didn't run" ;;
*) meaning="a migration failed" ;;
esac
echo "Migrations failed with exit code $exit_code ($meaning). $reason"
exit 1
fi
It starts by reading the migrations stack’s outputs, so the workflow only has to pass the stack’s name. Here’s how the rest works:
- Streaming the logs.
get-log-eventshands back a forward token with every page. You’ve reached the end of the stream (so far) when it hands back the same token you asked with. - While the task runs, the script reads new lines every 5 seconds, at most ten pages at a time, so a chatty migration can’t hold up the status checks.
- Once the task stops, it keeps reading until three polls in a row bring nothing new.
- At the end, it turns the container’s exit code into a message using the table from earlier.
All it needs is the AWS CLI and jq, and GitHub’s Ubuntu runners come with both.
There’s one thing it deliberately doesn’t do, and that’s give up and stop the task. Stopping a task halfway through a migration that isn’t in a transaction is exactly how you get one stuck as applying. If something really does hang, the job’s timeout-minutes ends the job, the task carries on, and the lock is released when it finishes.
And here’s the payoff: the same output we saw on my machine, counts and all, now in the Actions log and running against production:
Run ./scripts/run-migrations.sh MyApp-Migrations-production
Started arn:aws:ecs:eu-west-2:123456789012:task/myapp-migrations-production/7d1c9e04a2b54f6c9e8a31f0b5d27c4e
Migrations task is PROVISIONING
Migrations task is PENDING
Migrations task is RUNNING
2026-11-02T09:14:36Z Runner: Kevsoft.MigrateMongo 1.0.0 · MongoDB.Driver 3.13.0 · .NET 10.0.14 · protocol 1
2026-11-02T09:14:41Z Acquired lock (run 8e2f5a17c04d4b9a9f6e2d3c1b0a7f58).
2026-11-02T09:14:42Z ▶ 20261022090000 backfill-line-totals
2026-11-02T09:14:43Z warning: 3 orders have no lines.
2026-11-02T09:14:44Z ✔ 20261022090000 backfill-line-totals (2.43s, transaction)
2026-11-02T09:14:44Z backfilled matched 41207 · modified 41204 · upserted 0
2026-11-02T09:14:44Z emptyOrders 3
2026-11-02T09:14:44Z 1 operation(s) recorded – migrate-mongo show 20261022090000
2026-11-02T09:14:44Z Applied 1 migration(s) in batch 2.
Migrations task is DEPROVISIONING
Migrations task is STOPPED
As for timings, getting from run-task to that first line takes around 40 seconds, while Fargate finds some capacity and pulls the image. Another 5 or so go on looking up the cluster and signing in with the task’s role, and then the migrations themselves. All in, the migrate step adds a minute or two to a deploy. The very first deploy takes a little longer, because CloudFormation has to create the stack (about 40 seconds for ours).
The best bit: every operation, written down
Logs are great while you’re watching. But CI logs get tidied away (GitHub keeps them for 90 days by default), CloudWatch’s get tidied away too, and the questions I actually get asked about a migration tend to come months later. What exactly did that change in production? How many orders did it touch? What did that index look like before we dropped it?
This is my favourite part of the tool. Everything a migration records is stored with it, on its document in _migrations and on its history entry, so the database itself remembers. show puts the lot on one screen:
$ migrate-mongo show 20261022
Runner: Kevsoft.MigrateMongo 1.0.0 · MongoDB.Driver 3.13.0 · .NET 10.0.14 · protocol 1
20261022090000 backfill-line-totals
State applied (batch 2, up, transaction)
Applied 2026-11-02 09:14:44 by app@ip-10-0-3-17 (2.38s)
Checksum sha256:0c42cc28e360b5df3578c229212681dbf10437d22188167b6214023c32797a79
Source csharp MyApp.Migrations.Migrations.BackfillLineTotals
Versions tool migrate-mongo/1.0.0 (linux-x64; aot), library 1.0.0, driver 3.13.0
Run 8e2f5a17c04d4b9a9f6e2d3c1b0a7f58
Results
backfilled { matched: 41207, modified: 41204, upserted: 0 }
emptyOrders 3
Notes
09:14:43 warning 3 orders have no lines.
Operations
1. updateMany orders [backfilled] matched 41207 · modified 41204 · upserted 0 (2.21s)
filter: { Lines.LineTotal: { $exists: false } }
update: [{ $set: { Lines: { $map: { input: "$Lines", as: "line", in: { $mergeObjects: ["$$line", { LineTotal: { $multiply: ["$$line.UnitPrice", "$$line.Quantity"] } }] } } } } }]
History
At ID Name Event Batch Run Host Error Results
2026-11-02 09:14:44 20261022090000 backfill-line-totals applied 2 8e2f5a17 ip-10-0-3-17 backfilled: matched 41207 · modified 41204 · upserted 0, em…
There’s a lot packed into that, so let me pick out what makes it so useful:
- The counts are MongoDB’s, not yours. They come straight from the server’s result, so a migration that quietly modified nothing can’t hide behind a cheerful log line.
modified 0would be sitting right there, just like the three empty orders are here. - You get the statement that actually ran. The exact filter and update, which is the first thing you reach for when the numbers look odd. If your filters contain data you’d rather not keep,
results.captureStatements: falseturns statements off and keeps only the kinds, counts and timings. - Dropping an index records it first. A tracked
DropIndexAsyncsaves the index’s full definition before dropping it, so a migration (or a rollback) that removes an index leaves you the recipe to put it back. - Failures keep what they recorded. A migration that fails keeps everything it recorded up to that point. If its transaction was aborted, the results are marked
resultsCommitted: falseand the log says(transaction aborted – changes not persisted), so you can tell what it tried from what actually stuck. -
It’s all just documents. Results are stored as native BSON, so you can query them like anything else. Every migration that has ever touched the orders collection is one query away:
db["_migrations"].find({ "operations.collection": "orders" }, { name: 1, results: 1 })
And because show is just another command, you can run it right where the database is, with the very same task. No bastion needed to look, either:
aws ecs run-task --cluster "$cluster" --task-definition "$task_definition" --launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$subnets],securityGroups=[$security_group]}" \
--overrides '{"containerOverrides":[{"name":"Migrations","command":["show","20261022","--runner","./MyApp.Migrations"]}]}'
Wiring up the workflow
Here’s the workflow, trimmed down to the interesting parts:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
- run: dotnet tool restore
- run: dotnet migrate-mongo bundle -r linux-x64 -o bundle
- run: docker build -t myapp-migrations:${{ github.sha }} -f Dockerfile.migrations .
# ...and on main, push it to ECR
test:
runs-on: ubuntu-latest
services:
mongo:
image: mongodb/mongodb-atlas-local:8.0
ports: ["27017:27017"]
options: --health-interval 5s
env:
MONGODB_URL: mongodb://localhost:27017/?directConnection=true
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
- run: dotnet tool restore
- run: dotnet migrate-mongo up
- run: dotnet migrate-mongo down --batch --no-build
- run: dotnet migrate-mongo up --no-build
- run: dotnet migrate-mongo validate --no-build
deploy-production:
if: github.ref == 'refs/heads/main'
needs: [build, test]
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/myapp-deploy-production
aws-region: eu-west-2
- run: npx aws-cdk@2 deploy MyApp-Migrations-production -c imageTag=${{ github.sha }} --require-approval never
- run: ./scripts/run-migrations.sh MyApp-Migrations-production
- run: npx aws-cdk@2 deploy MyApp-production -c imageTag=${{ github.sha }} --require-approval never
The test job runs the migrations against a throwaway mongodb-atlas-local service container. It’s a single-node replica set, so transactions work just like they do in Atlas. Applying everything, rolling it back and applying it again catches a migration that doesn’t undo cleanly long before anyone merges it.
The image has a built-in health check, and the runner won’t start your steps until the service reports healthy. You’ll see a “Waiting for all services to be ready” group at the top of the job. The check only runs every 30 seconds, though. Anything in options is passed to docker create, so --health-interval 5s speeds it up without replacing it.
A few things in the deploy job are worth calling out:
- There are two stacks, deployed in that order. Anything in the app’s stack that takes new code, like a task definition or a Lambda function, would ship before the migrations if they shared a stack. (Even a C# CDK app gets deployed with the Node CLI, hence the
npx.) cancel-in-progress: false. Cancelling a deploy halfway through a migration is exactly what we don’t want. The tool’s lock would cope with two runs anyway, but belt and braces.- Required reviewers on the
productionenvironment give you a manual “are you sure?” before anything touches production. Be warned, though: on the Free, Pro and Team plans they only work on public repositories.
Pull requests only get build and test, so nothing in a PR ever touches a real database.
Writing migrations that run before the code
Running migrations first changes how you write them, so here are a few rules we stick to:
- Track what you touch. Wrap collections with
context.Trackand record anything worth knowing later incontext.Results. Future you will be grateful. - Expand, then contract. The old code is still serving traffic while the migration runs, and if the deploy rolls back, it’s the old code again afterwards. Every migration has to work with the code that’s running now. Removing things waits for a later release.
- Keep them quick and idempotent. A transactional migration can be retried, and the job has a time limit. A long, throttled backfill doesn’t belong in a deploy gate.
- Never edit one that’s been applied. The checksum will catch you, so write a new one instead.
- Allow out-of-order migrations (
allowOutOfOrder: true) if pull requests merge in any order. A migration’s id is the time it was created, not the time it was merged.
Wrapping up
So, back to that decimal migration. With this setup, it would have failed in the migrate step, the build would have gone red, and the new code would never have shipped against data it couldn’t handle. And if it had ever “succeeded” while changing nothing, the counts would have said so, in the job log and in the database, for as long as we cared to look.
What we ended up with is pleasingly boring: no bastion, no database credentials in CI, and a permanent record of every operation each migration ran.
How do you run your migrations, and how do you find out afterwards what they did? On start-up, in CI, or by hand on a Friday afternoon? Let me know in the comments.