Recently I have been researching a way to automate the process of deploying some of my hobby projects. Frankly speaking nowadays I just pack everything in a docker container and call it a day, but as I rather very low-end servers (💸) I didn’t want to bring Swarm/Kubernetes overhead to the table (I had some pretty nice experience with Swarm a few years ago, but it seems k8s is eating the industry if you like it or not 🤷♀️).
There is dokku - but it runs on a single server only (which doesn’t fit into my existing setup where I have separated applications from database services). There is Nomad by well-known Hashi Corp which seemed like a good fit - but at the same time, I wasn’t into setting everything up from scratch. Then I stumbled upon webhook and with a help of Ansible it seemed like a right fit for my simple needs.
Current approach
The way it worked so far is that I have services, a private docker registry and I simply build the images, push them to my private registry and redeploy containers - via ansible or other means, but obviously, that requires ssh access to the server. Once I started doing some pet projects with my partner I needed to automate it a little bit more.
What I’m trying to solve here
Automate the way of building docker image in a repository - stopping container on the server, pulling new image, and spinning container with the updated image. No fancy orchestration is required, few seconds on downtime is acceptable.
The brand new setup
- I will utilize github actions and their new shiny github packages so adding workflow file to the repository and a simple script on the server for deployment will make the problem go away
- I’m gonna deploy webhook in a docker container as I’m already using traefik with docker provider enabled
Step 1
Configure webhook so it can take control over docker on the system. Things will get a little bit weird here - as I want to deploy webhook in a docker container and at the same time give it control over a host docker. How to do that? Well, first we’re gonna customize the container we’re gonna use:
FROM almir/webhook:2.8.0
RUN apk --update --upgrade add docker curl bash && \
rm -rf /var/cache/apk/*
Then I’m gonna mount docker.sock
in that container as well as image
and overlay2
docker directories. The recipe is loosely based on this comment. You can find some ansible snippets below.
Step 2
Next let’s create a github action workflow for one of the my projects:
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches:
- production
jobs:
build:
name: Build image
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: docker/[email protected]
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/[email protected]
# caching docker layer ommited for brevity
- name: Build and push
uses: docker/build-pu[email protected]
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [ build ]
steps:
# One of the way of passing envs to the http endpoint
# We're gonna pass it as base64 encoded string
# And then allow 'webhook' to generate a temporary file
# which then will be passed down to docker run command
- name: Generate encoded env file
id: env_file
run: |
echo "SOME_VARIABLE=123" >> .env
echo "SOME_TOKEN=${{ secrets.SOME_TOKEN }}" >> .env
echo "::set-output name=encoded::$(cat .env | base64 -w 0)"
rm .env
- name: Deploy docker container webhook
uses: joelwmale/[email protected]
with:
url: https://yourwebhookdomain.com/hooks/your-deploy-endpoint
headers: '{"X-Token": "${{ secrets.WEBHOOK_TOKEN }}"}'
body: >-
{ "DOCKER_USERNAME": "${{ github.repository_owner }}",
"DOCKER_REGISTRY": "ghcr.io",
"DOCKER_PASSWORD": "${{ secrets.GITHUB_TOKEN }}",
"DOCKER_IMAGE": "ghcr.io/${{ github.repository }}:latest",
"ENV_FILE": "${{ steps.env_file.outputs.encoded }}" }
There are quite a lot of moving parts going on here, let’s break it down:
first, we’re building a docker image using Github’s ghcr.io registry - we’re gonna use very convenient magical github token secret that gives us scoped access to the repository and registry during the execution of the workflow
then we’re gonna prepare env file on the fly and base64 encode it for webhook - it’s maybe not the best practice here, but later on, we will be able to use it as
--env-file
argument when we will re-spin our containerwebhook should be accessible under
yourwebhookdomain.com
- we’re also using github secrets to setWEBHOOK_TOKEN
secret - we will use simple token-based authentication for our deployment
Step 3
Time for Webhook container configuration - here is ansible snippet with some comments:
- name: Create webhook container
docker_container:
name: webhook
# we're using our customized webhook image - see dockerfile in step 1
image: webhook-with-docker
state: started
restart: yes
restart_policy: always
volumes:
- /home/webhook/:/etc/webhook:ro
# we're giving control to docker on the host
- /var/run/docker.sock:/var/run/docker.sock
# and allow to write files to docker as well - you might not need that part
- /var/lib/docker/image:/var/lib/docker/image
- /var/lib/docker/overlay2:/var/lib/docker/overlay2
command:
# add verbose flag for debugging purposes!
# - '-verbose'
- '-hooks=/etc/webhook/hooks.json'
- '-hotreload'
labels:
# traefik labels - your configuration may vary
traefik.enable: 'true'
traefik.docker.network: 'traefik'
traefik.http.routers.router-https-webhook.rule: 'Host(`yourwebhookdomain.com`)'
traefik.http.routers.router-https-webhook.entrypoints: 'websecure'
traefik.http.routers.router-https-webhook.tls.certResolver: 'letsencrypt'
traefik.http.services.balancer-webhook.loadbalancer.server.port: '9000'
networks:
- name: traefik
Example webhook - that is accessible under /home/webhook/hooks.json
on the host in my case:
[
{
"id": "your-deploy-endpoint",
"execute-command": "/etc/webhook/your-deploy-endpoint",
"response-message": "Restarting container",
"pass-environment-to-command":
[
{
"envname": "DOCKER_REGISTRY",
"source": "payload",
"name": "DOCKER_REGISTRY"
},
{
"envname": "DOCKER_USERNAME",
"source": "payload",
"name": "DOCKER_USERNAME"
},
{
"envname": "DOCKER_PASSWORD",
"source": "payload",
"name": "DOCKER_PASSWORD"
},
{
"envname": "DOCKER_IMAGE",
"source": "payload",
"name": "DOCKER_IMAGE"
}
],
"pass-file-to-command":
[
{
"source": "payload",
"name": "ENV_FILE",
"envname": "ENV_FILE",
"base64decode": true,
}
],
"trigger-rule-mismatch-http-response-code": 411,
"trigger-rule":
{
"match":
{
"type": "value",
"value": "{{ here goes value of WEBHOOK_TOKEN from Github secrets }}",
"parameter":
{
"source": "header",
"name": "X-Token"
}
}
}
}
]
So finally how /etc/webhook/your-deploy-endpoint
(mounted from /home/webhook
on the host) looks like? It’s a simple bash script that just log-in into the docker registry, pulls a new image, and re-spins the container - so it does exactly what we were planning to solve here, just in an automated manner.
#!/bin/bash
set -e
docker login $DOCKER_REGISTRY -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
docker pull $DOCKER_IMAGE
docker stop my-app || true
docker rm my-app || true
docker run -d \
--restart unless-stopped \
--name my-app \
--env-file=$ENV_FILE \
$DOCKER_IMAGE
docker logout $DOCKER_REGISTRY
Don’t worry that $ENV_FILE
won’t exist upon container manual restart (as it gets removed one webhook does its job) - if you do docker inspect my-app
you should see that environment variables from the file were already loaded and file itself is no longer required.
Summary
So it might feel a little bit intimidating (and not easy!) at first but once you wrap your head about all the goodies that github provides and how it can fit nicely with webhook
you might find should approach suits your needs.
Presented code shouldn’t be treated as a production-ready example and more like a walk-through for automating the deployment of your hobby project - there are a lot of things that could potentially go wrong here - as we’re happily passing envs to a shell command and spinning some images ;).
Happy deploying!