Let’s say we’re using GitHub Actions to build and publish a container image of our app. I’m gonna pick ASP.NET Core as the app’s tech stack here, although that shouldn’t matter much.
There are two different approaches I’d like to discuss:
1. “Build outside”: build/compile app in GitHub Actions runner, copy output into container image
For example, our GitHub Actions workflow file could look like this…
name: build-outside
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
- name: .NET Publish
run: dotnet publish --configuration Release --nologo -p:CI=true -o $GITHUB_WORKSPACE/buildOutput src
- name: Build and push Docker image
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
repository: ${{ format('{0}/build-outside-test', secrets.DOCKERHUB_USERNAME) }}
tags: latest
… and there’s a simple Dockerfile like this:
FROM mcr.microsoft.com/dotnet/core/aspnet:latest
WORKDIR /app
COPY buildOutput /app
ENTRYPOINT ["dotnet", "MyTestApp.dll"]
2. “Build inside”: build in one container, copy output to another container image
In this case, the workflow file is shorter…
name: build-inside
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v2
- name: Build and push Docker image
uses: docker/build-push-action@v1
with:
dockerfile: Dockerfile_build_inside
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
repository: ${{ format('{0}/build-inside-test', secrets.DOCKERHUB_USERNAME) }}
tags: latest
… whereas the Dockerfile is longer, as this is now where we’re building the app itself and the final container image:
FROM mcr.microsoft.com/dotnet/core/sdk:latest AS build
WORKDIR /src
COPY src /src
RUN dotnet publish --configuration Release --nologo -p:CI=true -o ./buildOutput
FROM mcr.microsoft.com/dotnet/core/aspnet:latest AS runtime
WORKDIR /app
COPY --from=build /src/buildOutput ./
ENTRYPOINT ["dotnet", "MyTestApp.dll"]
Aside: in case you’re not familiar with multi-stage
builds,
note the two FROM
statements in that second Dockerfile. We’re
building in a first, temporary container, and then copying only the
build output into the final (runtime-optimized) container image.
Note that this second approach is explicitly recommended in the official ASP.NET Core documentation.
Trade-offs
I’ve confirmed that both approaches work and produce a working container image. Notably, build checks on pull requests “just work”™ with both approaches:

Now stepping away from this concrete example, here’s my current thinking on the advantages of each approach in general:
- Build outside:
- Build can leverage Marketplace Actions
- If build is complex and consists of several steps, it might be beneficial to set it up using GitHub Actions primitives – i.e. a series of jobs/tasks. That way, we can leave it to GH to optimize the build, allocate additional resources as needed, run jobs in parallel etc.
- A little easier to inspect build failures (UI will show exactly which step failed)
- No need to download 2nd container image during build, hence maybe saving a little bit of network bandwidth
- Build inside:
- Exact, deterministic build output
- Full control over build environment; independent of build runner
- Container build can also run on local dev machines, producing same exact output
Questions
-
Am I accurately describing the advantages of the two approaches?
-
Are there any other aspects of building inside vs outside a container, specifically in GitHub Actions, which are worth mentioning?