Automate deploy to VPS with Docker and GitHub Actions
20/04/2024

Contents
Introduction
In the previous article, we established a setup for our project on a VPS. However, this setup has a significant drawback: every time you need to update your project, you must:
- Commit and push changes to your GitHub repository.
- SSH into your VPS and pull changes from the repository.
- Run docker-compose to build and deploy your new release.
This process involves switching environments and manually running Docker commands on your VPS, which can be cumbersome and slow, especially when deploying urgent fixes. Let’s simplify our lives by automating these deployment steps.
Image to Registry
The first step we’ll automate is building and storing the Docker image. Instead of handling this on our VPS, we will set it up to happen automatically in a remote registry using GitHub Actions. GitHub Actions is a feature integrated into GitHub that allows you to run pipelines for your projects. To enable this, create the following directory and file in your project:
.github/workflows/docker-publish.yml
The content of this file should look something like this:
#
name: Create, publish and deploy
# Configures this workflow to run every time a change is pushed to the branch called `main`.
on:
push:
branches: ['main']
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.actor }}/astro-blog:latest
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'ci skip')"
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build and push Docker image
run: |
docker build . --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
The steps in this YAML file are self-explanatory. I borrowed parts of this file from the one used for this website.
Two important points to note:
- You can skip this entire workflow by simply adding ci skip to your commit message, preventing a rebuild on that particular commit, thanks to this line:
if: "!contains(github.event.head_commit.message, 'ci skip')"
- For the step named Log in to the Container registry, you can use a pre-built action from the marketplace, such as this one. However, to understand what happens behind the scenes, we’ve provided a more detailed version. Here, we use
secrets.GITHUB_TOKEN
to log into the GitHub Container Registry (ghcr.io). This token is automatically generated by GitHub Actions, so you don’t need to worry about generating and renewing a Personal Access Token for this use case.
Deploy
Now that our image is built and stored in the registry, we must still manually pull it on our VPS and instruct Docker to deploy the new container. To automate this, we’ll update our docker-publish.yml
file to include a new job named deploy, which only runs after the build-and-push-image job is complete.
#
name: Create, publish and deploy
# Configures this workflow to run every time a change is pushed to the branch called `main`.
on:
push:
branches: ['main']
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.actor }}/astro-blog:latest
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'ci skip')"
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build and push Docker image
run: |
docker build . --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
deploy:
needs: build-and-push-image
name: deploy image
runs-on: ubuntu-latest
steps:
- name: install ssh keys
# check this thread to understand why its needed:
# <https://stackoverflow.com/a/70447517>
run: |
install -m 600 -D /dev/null ~/.ssh/gthb_ctn
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/gthb_ctn
ssh-keyscan -H ${{ secrets.SSH_HOST }} > ~/.ssh/known_hosts
- name: connect and pull
run: ssh -i ~/.ssh/gthb_ctn ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "cd ${{ secrets.WORK_DIR }} && docker compose pull && docker compose up -d && exit"
- name: cleanup
run: rm -rf ~/.ssh
This job will require several secrets to be added to your repository:
- SSH_PRIVATE_KEY: the private part of your SSH key.
- SSH_USER: the username for the user on your VPS.
- SSH_HOST: the address of your VPS.
- WORK_DIR: the directory containing
docker-compose.yml
for deploying your project.
SSH Private Key
It’s crucial that the GitHub Actions worker logs into your VPS without a password. Here’s how to set it up:
- Generate SSH keys on your local machine.
- Add your public key to
.ssh/authorized_keys
on your VPS. - Use the private key in your GitHub Actions setup, as already done in our
docker-publish.yml
.
For more details on SSH key setup, refer to this article.
Docker Compose
Our GitHub Actions workflow is almost ready. The final step is to adjust docker-compose.yml
from our previous article to no longer build from the source code on the VPS:
frontend:
- build: .
+ image: <path.to/image/in:registry>
container_name: frontend
env_file:
- hosts.env
Instead, we specify in the Docker Compose file to pull the image from the registry, where it was pushed by the docker push
command during the GitHub Actions workflow.
Conclusion
That’s it! Now, whenever you push changes to the ‘main’ branch without the ‘ci skip’ in the commit message, the GitHub Actions workflow will start. It will build a new image from your source code, push it to the registry, log into your VPS, navigate to the working directory with docker-compose.yml
, and redeploy the containers.