Make your own Heroku! Auto-deploy a Docker app on your own server with GitHub Actions
Tags: docker, github, devops
Published: 2021-09-20 20:44:47

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:

{% github toficofi/auto-deploy-docker %}

-----
## 🎯 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.

```bash
$ 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`.

![Untitled-1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w0otb8ojrwcy73e2qvt6.png)
 
 
-----

### 🐳 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](https://docs.docker.com/samples/dotnetcore/) as it fits our needs. I've annotated the file with comments.

```dockerfile
# 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.

```dockerignore
bin/
obj/
```
*Note: For deployment pipelines, it's [generally preferred to build the project outside the container](https://docs.docker.com/samples/dotnetcore/#method-2-build-app-outside-docker-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!

```bash
# 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](https://docs.docker.com/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:

```yaml
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`)

```bash
# 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](https://github.com/git-guides/), 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](https://www.chiark.greenend.org.uk/~sgtatham/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.](https://us.ovh.com/us/order/vps/?v=3#/vps/build?selection=~(range~'Starter~flavor~'vps-starter-1-2-20~datacenters~(BHS~1)~pricingMode~'default))

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.**

```bash
$ git config --global credential.helper store
```

Next, clone your repo. 

```bash
$ 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](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-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.

```bash
# 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:

1. Pull down the updated `live` branch
2. Build the image (which will also build the project)
3. Tear down the existing containers
4. 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:

```bash
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!

```bash
$ 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*.

![aa](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o10cr2ga7eufwj4r7fcm.png)

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.](https://docs.github.com/en/actions)

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:

```yaml
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).

![secrets](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3p5wu96r9exirddmal8r.png)

*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!"

```html
<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!

![done](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ghdhxrkqnsv51ya5l9x0.png)
 
-----

## 🎁 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](https://github.com/GustavoKatel/pushbullet-cli)

🎉 Thanks for reading! [You can find me on Twitter here.](https://twitter.com/toficofi) and on [LinkedIn](https://www.linkedin.com/in/josh-lee-15459b10a/) here.