Make your own Heroku! Auto-deploy a Docker app on your own server with GitHub Actions
Josh Lee
Posted on September 20, 2021
If you've switched from using PaaS services like Heroku to running off a VPS (or your own machine!) then you might miss features like auto-deploy.
Luckily, with the power of β‘ GitHub Actions and Docker, it's simple to replicate auto-deploy yourself.
You can find the final example repository below:
π― The Goal
We'll be able to git push origin live
some changes, and find that your server has automatically pulled the changes, built a new image, and restarted the containers, without lifting a finger.
π§ What do I need?
This guide assumes a basic understanding of Docker, Git, and bash, and that you have a VPS or another Linux machine already provisioned. The stack for your app doesn't matter here!
π Let's get started!
Our app
In this example, we'll be using the default ASP.Net Core webapp template. It doesn't matter what stack you use, as long as it's Dockerized.
$ mkdir auto-deploy-docker
$ cd auto-deploy-docker
$ dotnet new webapp
$ dotnet run dev
(local shell)
That'll get us started with a simple webapp, found at localhost:5000
.
π³ Docker time
Drop a Dockerfile
at the root of the project. We're going to steal the config from Dockerize an ASP.NET Core application as it fits our needs. I've annotated the file with comments.
# syntax=docker/dockerfile:1
# First stage: build the app
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build-env
# Make and switch to the /app directory
WORKDIR /app
# Copy the C# project file into /app
COPY *.csproj ./
# Restore dependencies
RUN dotnet restore
# Now we can copy the rest of the source into /app
COPY ./ ./
# Build the project in Release mode to the "out" directory
RUN dotnet publish -c Release -o out
# Second stage: run the app
FROM mcr.microsoft.com/dotnet/aspnet:5.0
# Switch back to /app
WORKDIR /app
# Copy the binaries from the build stage into our new stage
COPY --from=build-env /app/out .
# Set the entrypoint for when the image is started
ENTRYPOINT ["dotnet", "auto-deploy-docker.dll"]
To reduce the size of the image, drop a .dockerignore
so we don't copy in the binaries from the context. We don't need these, because we're building the project inside the container.
bin/
obj/
Note: For deployment pipelines, it's generally preferred to build the project outside the container, and use the compiled binaries in the image. We won't worry about that for this guide.
Let's build the image and verify it works locally. Make sure you exit the dotnet run dev
you started earlier, so it doesn't hog up port 5000!
# build the image
$ docker build . -t auto-deploy-docker
# run the image, binding the container's 80 to our 5000
$ docker -p 5000:80 run auto-deploy-docker
(local shell)
Navigate again to localhost:5000
and you should see the same app.
Great! The last thing we'll want to do here is make use of the powerful Docker Compose. If you've not used Compose yet, it's basically a way to define all the containers we need to use for our app with a docker-compose.yml
configuration file, which means we can put them all up and tear them all down in one go without faffing around with other commands.
In this example, we're only running a single webapp in a container, but if you need to add other services - like a background worker, or a database container, Compose helps you orchestrate them. On top of that, it lets you configure containers to automatically restart when they go down or the host machine is restarted.
Make a docker-compose.yml
and fill it out as so:
version: "3.8"
services:
auto_deploy_docker: # name of the service
image: auto-deploy-docker # the image to use
container_name: auto-deploy-docker # what to label the container for docker ps
ports:
- 80:80 # note, we're mapping to port 80 instead of 5000 because we'll use 80 on the VPS
restart: unless-stopped # restart if failed, until we stop it ourselves
Give it a shot (again, make sure you stop the previous docker run
)
# bring everything up (will run in background)
$ docker-compose up -d
# tear everything down
$ docker-compose down
(local shell)
Nice. We've got everything up and running locally. Commit your app to GitHub, and make a live
branch that reflects what will be live on our VPS. This is where we'll push changes to have them deployed on the server.
π» Dropping our app on the VPS
It's time to take things remote. SSH into your VPS (I'm using PuTTY), and make sure you have Git and Docker installed. I'm using a Debian with Docker image from OVH. At the moment, they're doing a nice $3.50 a month VPS.
Start by enabling credential caching. This will save our GitHub username and Personal Access Token. It will, however, save it in plaintext, so make sure you have tight control over your VPS.
$ git config --global credential.helper store
Next, clone your repo.
$ git clone https://github.com/toficofi/auto-deploy-docker.git
$ cd auto-deploy-docker
Git will prompt you to log into GitHub if you haven't already. You can't use your GitHub password here, you'll need to generate a Personal Access Token and use that in place of a password. Limit the scopes for the PAT. We only need repo
access on the VPS.
Great! Let's give it a test run.
# build the image on the server
$ docker build . -t auto-deploy-docker
$ docker-compose up
$ git checkout live # switch to our live branch
(remote shell)
Navigate to your server's IP address, and you should see the page up and running. Fantastic!
Next up, we'll write a bash script for automatically pulling changes and deploying them.
π The deploy script
Let's recap where we are.
- We have an ASP.Net Core app, and we've Dockerized it.
- We've got it on GitHub, and the VPS has the repo pulled.
- We can run
docker-compose up
to start our container.
In order to respond to changes to the code, these are the steps we need to take:
- Pull down the updated
live
branch - Build the image (which will also build the project)
- Tear down the existing containers
- Start up the existing container
We'll wrap these steps up in a little deploy.sh
script that will handle this all with a single execution.
Make the deploy.sh
script:
echo "Deploying changes..."
# Pull changes from the live branch
git pull
# Build the image with the new changes
docker build . -t auto-deploy-docker
# Shut down the existing containers
docker-compose down
# Start the new containers
docker-compose up -d
echo "Deployed!"
Note: If you're developing on a Windows platform, make sure you change your line endings to use \r
(Unix-style) instead of \r\n
, or bash on the server won't understand the script.
Make the script executable, and give it a shot!
$ chmod +x deploy.sh
$ deploy.sh
(remote shell)
It might take a couple of minutes, but you'll be able to watch it pull changes, build your image, and start the containers up.
Connect to your VPS and you should see the app!
β‘ Setting up the GitHub Action
We've got our deploy.sh
script ready to go. The final piece of the puzzle is to set up a GitHub Action that will automatically invoke this script whenever the live
branch has been updated, completing the loop.
Jump onto your repo on GitHub, navigate to the Actions page and hit skip this and set up a workflow yourself.
You'll be presented with a templated Workflow script. It looks a little overwhelming, but it's really just a list of "actions". Each Action is triggered by something, like a push to a branch. It will then execute a sequence of steps in a GitHub-provided container. You can read more about Actions here.
We're just going to use the action to SSH and invoke our deploy.sh
script whenever the live
branch is pushed to.
Drop this workflow script in:
name: Deploy
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the live branch
push:
branches: [ live ]
pull_request:
branches: [ live ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
deploy:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- name: log into VPS and trigger deploy script
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }} # uses secrets stored in the Secrets tab
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORD }}
port: ${{ secrets.PORT }}
script: sh deploy.sh
This should be pretty self explanatory. Commit the workflow script to the live
branch.
We'll need to allow the Action to access our SSH host, username, password and port. Go to Settings > Secrets.
Add secrets for HOST
(IP/domain for your server), USERNAME
(ssh username), PASSWORD
(ssh password) PORT
(probably 22).
NOTE: These secrets are accessible to anyone with Contributor access to the repository, which means they'll be able to log into your VPS. Be careful.
With the secrets added, that should be us ready to go. Let's give it a whirl!
On your local machine, make a change to your app. For example, we'll change Index.cshtml
to show "Hello, Auto Deploy!"
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Hello, Auto Deploy!</a>.</p>
</div>
Push your changes to the live
branch, and jump onto GitHub to watch the action. You'll be able to see GitHub executing each step for you. Once it's done, navigate to your server IP/domain and some β¨magicβ¨ has happened!
π Wrapping up
Now that you've completed this guide, you'll be able to deploy your changes on your VPS with a simple git push
. This will save you a ton of time, and makes a foundation for a CI/CD pipelines.
Here are some next steps you might take:
- Run some tests as part of your deployment pipeline
- Build the app using GitHub Actions, and send over the binaries via SCP instead of maintaining a repo on the VPS
- Send yourself a notification when the deployment has completed with PushBullet CLI
π Thanks for reading! You can find me on Twitter here. and on LinkedIn here.
Posted on September 20, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.