
EF Core Migrations In Docker
Running EF Core Migrations bundles in a docker container
Entity Framework core is a widely used ORM that is loved or hated by developers everywhere. If your team has decided to use ef core for your projects, you'll eventually find yourself wondering how to apply the migrations in prod. Microsoft has great documentation around this here. They give you some pretty clear options on what to do, and each option has its own tradeoffs.
I'm going to focus on one of these options: The efbundle. The benefit of the efbundle is it's a standalone package that you can run anywhere. So naturally, I'm going to stick it in a docker container.
The Dockerfile
The first step with any docker container is to create the dockerfile. We're using a multi-stage dockerfile for this exercise. Let's walk through it.
1FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS base
2
3USER $APP_UID
At the top of our dockerfile is pretty standard Dockerfile stuff. We're bringing in our base image, and since we are using dotnet core 10, we are using the dotnet/runtime image from Microsoft Container Registry. This makes sure we are using a relatively lightweight image that still has what we need.
1FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
2WORKDIR /source
3
4COPY ejsmith.me.ApiService/*.csproj ejsmith.me.ApiService/
5
6RUN dotnet restore ejsmith.me.ApiService/ejsmith.me.ApiService.csproj
7
8COPY . .
9RUN dotnet build ejsmith.me.ApiService/ejsmith.me.ApiService.csproj -c Release --no-restore
Now we are setting up our build stage. If you're familiar with building dotnet projects, this is probably familiar for you.
1FROM build AS publish
2
3ENV PATH="$PATH:/root/.dotnet/tools"
4
5RUN dotnet tool install --global dotnet-ef
6
7WORKDIR /source
8RUN dotnet ef migrations bundle \
9 --output /source/efbundle \
10 --self-contained \
11 --verbose \
12 --no-build \
13 --configuration Release \
14 --project ejsmith.me.ApiService \
15 --startup-project ejsmith.me.DataService \
16 --context DbContext
17
18RUN chmod +x /source/efbundle
Now let's set up our publish stage, using our build image. First, we need to make sure dotnet-ef is installed on our container, so we can create the bundle. In order for the newly installed tool to be available, we are adding /root/.dotnet/tools to the path.
Next, we are actually creating the bundle using the dotnet ef migrations bundle command. Note: I'm using the --self-contained switch, so the output is a self-contained executable. This still requires the dotnet runtime to be installed in our container, but it includes the EF dependencies it needs to run.
The --startup-project and --project parameters are optional, and for the use case where you have a Data project separate from your main API project.
Our last command for this stage is to make sure our newly created efbundle has the executable flag on it, so we can actually run it.
1FROM base AS run
2ARG DB_CONNECTION_STRING
3WORKDIR /app
4COPY --from=publish /source/efbundle .
5
6CMD ["sh", "-c", "./efbundle --verbose --connection \"${DB_CONNECTION_STRING}\""]
Here we are at the run stage. We're using the base image, and taking in a DB_CONNECTION_STRING argument. To run our efbundle, we are using the sh command (so we tell docker build that we really want to do a shell command here). We then form up our efbundle command, passing in the DB_CONNECTION_STRING arg. We do this because variable expansion doesn't happen when you use json CMD arguments in Docker. So, we are using sh -c and then passing in our actual command to make sure variable expansion happens at runtime.
The final result
Let's put all the stages together now into a single Dockerfile. Here's mine:
1FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS base
2
3USER $APP_UID
4
5FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
6WORKDIR /source
7
8COPY ejsmith.me.ApiService/*.csproj ejsmith.me.ApiService/
9
10RUN dotnet restore ejsmith.me.ApiService/ejsmith.me.ApiService.csproj
11
12COPY . .
13RUN dotnet build ejsmith.me.ApiService/ejsmith.me.ApiService.csproj -c Release --no-restore
14
15FROM build AS publish
16
17ENV PATH="$PATH:/root/.dotnet/tools"
18
19RUN dotnet tool install --global dotnet-ef
20
21WORKDIR /source
22RUN dotnet ef migrations bundle \
23 --output /source/efbundle \
24 --self-contained \
25 --verbose \
26 --no-build \
27 --configuration Release \
28 --project ejsmith.me.ApiService \
29 --startup-project ejsmith.me.DataService \
30 --context DbContext
31
32RUN chmod +x /source/efbundle
33
34FROM base AS run
35ARG DB_CONNECTION_STRING
36WORKDIR /app
37COPY --from=publish /source/efbundle .
38
39CMD ["sh", "-c", "./efbundle --verbose --connection \"${DB_CONNECTION_STRING}\""]
Testing the dockerfile
Building
Building our docker container is a simple docker build command. If you want to customize the tags or specify a path to your dockerfile, you can do this:
1docker build --tag "yourtag/yourimage" --file "Dockerfile" .
Running the migrations
If you're using Aspire (and if you're not, you should), testing this is pretty easy.
Aspire Setup
In your AppHost.cs, you can create a new SqlServer resource and database like this:
1var sqlTest = builder.AddSqlServer("SqlTest")
2 .RunAsContainer(static container =>
3 container.WithLifetime(ContainerLifetime.Persistent)
4 .WithDataVolume()
5 .WithHostPort(14301));
6
7sqlTest.AddDatabase("SqlTestDb");
You don't have to specify the host port, it's just easier to have a static port for future connections.
Next, run your aspire app.
1aspire run
Open up your aspire console, click on your SqlTestDb (make sure it's the Database, not the server), and copy the connection string. You'll need it for this next step.
Running the docker container
Now you're ready to execute your migrations! Use the docker run command, like this:
1docker run -e DB_CONNECTION_STRING="Server=host.docker.internal,14301;User ID=sa;Password={your_password};TrustServerCertificate=true;Initial Catalog=SqlTestDb" yourtag/yourimage
In the above command, you'll notice that the connection string is different than what you copied from the Aspire dashboard. In order to access the SQL from another docker container, it's easiest to just point it at the docker host and use the port we created above.
Now what?
We have our docker container working locally. Now, what was the point of this exercise, when we can just use dotnet ef locally?
I built this originally to be used in a deployment pipeline as part of our CI/CD process. We can build and publish the docker container to a container registry (such as Azure Container Registry), then pull it down and use it to execute our database migrations in a pipeline.
Actually using the Dockerfile in a pipeline is left as an exercise to the reader.
Conclusion
We've now talked about creating an efcore migrations bundle and putting it in a basic docker container for running later. We have a lightweight, version controlled method for running our database migrations in a CI/CD pipeline. This works well if you're already using container orchestration for your application. If not, the container registry overhead might not be worth it.
Either way, figuring out this solution was surprisingly pretty fun. Hopefully I discovered things that will be helpful for you as you build your own production-ready database deployments.