Building a CI / CD pipeline with ASP.NET Core, GitHub Actions, Docker and a Linux server
In this blog post, we will build a complete Continues Integration / Continuous Deployment pipeline for a sample ASP.NET Core Application.
This guide is intend for small to medium websites that are hosted on a single Linux server. Its the typical setup that you would use for personal projects or an small internal business application. Its easy, cheap and doesn't require some heavy deployment platform like Kubernetes or a cloud provider.
We still use Docker because it is a great technology for isolating multiple applications on one machine. If the server ever goes down, you can just reinstall Linux, install docker and redeploy the application.
The full source code of the example application can be found here.
The Pipeline
- Each commit to GitHub will be build automatically to verify your application can be build an works correctly.
- A Docker container is created and pushed to docker-hub.
- We connect remotely to a Linux server and replace the current container with an updated one.
Prerequisites
- This guide assumes you have installed the dotnet sdk. You can download the latest version here. We will use version 5.0 in this guide, but any version after this should work.
- You need a GitHub account and your project needs to be pushed to an repository.
- We will deploy the application to an Linux server. You need SSH access to the server and docker needs to be installed. No webserver like nginx or apache is required.
- Docker should be installed on your development machine. This is not a requirement but this way you can verify your dockerfile is working.
Building the application locally
If you haven't already, create a web application project:
dotnet new solution -n "MyWebApp"
dotnet new webapp -n "MyWebApp.App"
dotnet sln add ./MyWebApp.App/MyWebApp.App.csproj
If you now run “dotnet run” inside the “MyWebApp.App” directory, a webserver will spin up and you can open http://localhost:5000 in your browser. You should see the default “Welcome” page.
Since we want to create a docker container, we need to write a docker file. This file specifies all steps required to create the container. Copy the following code and put it in a file named “dockerfile” next to your sln file.
This dockerfile will actually create two docker images. The first one contains the dotnet sdk and your source code. It is used to build the application. The second one only contains the ASP.NET Core runtime and your application binaries. This is the image we will use later for our production deployment.
To build the container run: “docker build . -t mywebapp”. This creates a new docker image named “mywebapp”. Just as a sanity check: run “docker run -p 5000:80 -it mywebapp”. This will create a container from the image. If you now open http://localhost:5000 in your browser, you should see the webpage again.
If you get an error about a version mismatch: check if the dotnet version in your dockerfile and in “MyWebApp.App.csproj” matches (in this case: 5.0).
If everything works, commit all changes to git and push everything to GitHub.
Building the container with GitHub Actions and pushing it to Docker Hub
Now that our container can be build locally, we will move this process to GitHub.
Every time you push new commits, GitHub will check for the existence of an directory called “.github/workflows”. If this directory contains a yaml file, it will be parsed and executed. These files contain steps that define our build / deployment pipeline.
Create the directories “.github/workflows” in the root of your git repository. Inside this folder, add a file called “CICD.yaml” with the following content:(note that yaml is quite sensitive with whitespace, so don’t change the text indentation)
Ensure you replaced “username” with your actual docker hub username.
This workflow contains the following steps:
- Checkout: Get the source code from the repository
- Build the image: This step runs the “docker build” command. “Context” specifies the directory where the command is run. “Tags” specifies the name of the image. This name will be used when the image is pushed to docker hub. It must start with your docker hub username.
- Docker hub login: Before we can push the image to docker hub, we must log in. Note that we don’t put our credentials directly in this file. Use GitHub Secrets for sensitive information.
- Build and push: This step does the magic. We use a predefined GitHub Action step which calls “docker build” and “docker push” with the right arguments for us.
After you saved this file to “.github/workflows/CICD.yaml” commit all changes and push them to GitHub.
If you look into your GitHub account, you should see the workflow under the “Actions” tab. After a minute or so, it should complete successfully.
Congratulations, you completed the Continues Integration part of this guide. You should now be able to run “docker run -it -p 5000:80 username/mywebapp” on any machine that has docker installed.
Deploying the image to a server
After the image was pushed, we can now deploy the image to a Linux server. For this, we will add an additional step to our workflow definition. Add the following step to the bottom of your CICD.yaml:
Again: ensure you replaced “username” with your actual docker hub username. You can find the full version of this file in the sample repository here.
Also, make sure you add the servers hostname, username and password as secrets to GitHub Secrets. You don't want to push your server credentials into your repository.
How this workflow works:
- First we pull the updated version of the image.
- We stop an already running container with the name “mywebapp”. If no container with this name exists, this command will display an error. The script will still continue.
- We delete all containers that are no longer running. This will ensure the server does not run out of disk space after we deployed a lot of updates. You can remove this command if you have other containers on the machine that you want to keep.
- We now start the new container. The name for this container needs to match with the stop command above. Additionally we specify that this container should run until it is explicitly stopped. This makes sure the container gets started when the server is rebooted. If you have any other arguments that you like to pass to the container (like environment variables or volume mappings) you can add them here.
- Now that the old container no longer exists and the new one is started, we can delete all unused images.
That’s it. Push these changes to GitHub and the workflow should run again. A new image will be build, pushed and deployed to your server.