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

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]2

      - uses: docker/[email protected]1
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/[email protected]1

      - name: Cache Docker layers
        uses: actions/[email protected]2
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - name: Build and push
        uses: docker/[email protected]2
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new

      # Temp fix
      # https://github.com/docker/build-push-action/issues/252
      # https://github.com/moby/buildkit/issues/1896
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

  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:

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!