Clean blog deployment pipeline


I had an idea to host a blog by myself for a long time and finally got it done. The main thing which stopped me until this moment was a lack of experience in DevOps stuff and perfectionism in terms of how to make things easier for me.

All I wanted to do is write posts, push it to some repository. Another side of this pipeline looked so complex to implement.

On the other hand, everything complex is fun. When you have fun complex things become easier, you get knowledge and fun both.

This is a success story about how I set up continuous deployment for the blog.


  • Create a server on the remote VPS
  • Configure HTTPS for a domain using Let's Encrypt
  • Configure server once
  • Make it easy to configure on any other machine
  • Write posts locally with markdown
  • Commit and push changes to the Github Repository
  • Publish changes to the server automatically


I won't explain anything Hugo specific here. Hugo is a static site generator that results in public directory with all site content. This content needs to be served. I will use Nginx for it.

Dockerfile to build docker image from public directory using Nginx.

FROM nginx:alpine
COPY ./public /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf # You can omit this line if default nginx config is ok for you

I have this simple nginx.conf in the root of my project alongside Dockerfile. The main thing why I have this config overridden is the gorgeous 404 page.

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;

    error_page 404 /404.html;
    location = /404.html {
        root   /usr/share/nginx/html;

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;

Github actions

I use Github Actions as CI/CD tool to build and deploy the blog Docker image. It's a simple and straightforward solution built in Github.

Github Container Registry

We need Docker Registry to deploy our images into. I use Github Container Registry which was introduced recently. You can use Docker Hub for the same purpose.

To use GHCR we should use Container Registry Personal Access Token. To create this token go to your Github user Settings, go to Developer settingsPersonal access tokens, and click Generate new token. After filling the password this page will be opened. Check these checkboxes and click Generate token. Save result token for later use.

Token Rules


When you have something secret to use in Actions you should add it as Secret to be encrypted by Github. This way you can create your Action without any private data hardcoded.

To add Secret string you can go to Settings tab, go to Secrets page and click New Secret button.

Let's add Secret named CR_PAT add paste token we have from the previous step.


So now we have everything to write Github Actions workflow.

To create Action you shoud create .github/workflows directory with action *.yaml file.

Action file

I created a workflow called pipeline.yml using everything from the previous steps.

name: Site Deploy # Action name. Can be anything you want.

    branches: [ develop ] # Do this jobs on any push into develop branch

  pipeline: # Job name. Can be anything you want.
    runs-on: ubuntu-latest
      - name: Checkout
        uses: actions/checkout@v2 # Checkout project

      - name: hugo
        uses: klakegg/actions-hugo@1.0.0 # Run hugo action to create static `public` directory
          env: production # Set production env to separate local checks from production on

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v1 # Set up QEMU to use BuildX
          platforms: all

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v1 # Set up BuildX to use Docker Push action
          version: latest

      - name: Login to Registry
        uses: docker/login-action@v1 # Set up credentials for Docker registry
          username: ${{ github.repository_owner }} # Use repository name as GHCR username
          password: ${{ secrets.CR_PAT }} # Container Registry Personal Access Token provided via secrets

      - uses: docker/build-push-action@v2
          context: . # Current directory context to create image from
          file: ./Dockerfile # Use Dockerfile from repository
          push: true
          tags:${{ github.repository_owner }}/pavelkorolevxyz-web # Docker image tag to push

To see your actions you can go to Actions tab inside your repository.

Actions tab

Now when you push to the develop branch you can see action log directly on Github.

Actions tab


We will use Docker Compose to run things on VPS. By using Docker Compose instead of plain Docker we can write configs in nice yamls and this way we can connect multiple containers.

Set up the repository and install Docker

At first, we need to install Docker on our VPS. All my examples are from Ubuntu 20.04 VPS.

Set up repository to load docker from.

sudo apt-get update
sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common \
curl -fsSL | sudo apt-key add -
sudo add-apt-repository \
   "deb [arch=amd64] \
   $(lsb_release -cs) \

Install Docker.

sudo apt-get install docker-ce docker-ce-cli

More information in the Official Guide.

Install Docker Compose

To use compose we need to install it as well.

sudo curl -L "$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

More information in the Official Guide.

Log in with Github Access Token

export CR_PAT= # Set your token here
echo $CR_PAT | docker login -u username --password-stdin

Where $CR_PAT is Github Access Token from Github Settings. GHCR username should be provided instead of username.


Okay. Now we have all changes pushed to repository uploaded to the registry without any help. So we need something to get these changed images on our server machine.

Watchtower is a crazy simple tool. It just pulls the latest images from remote periodically and can be started as a separate Docker container. If the latest image and started one are different then Watchtower restarts the container using a new image. Let's see how it is set up using Docker Compose.

    image: containrrr/watchtower # Watchtower image
    restart: unless-stopped # Set container to restart on exit or Docker restart
      - /var/run/docker.sock:/var/run/docker.sock # Listen for docker container changes
      - /root/.docker/config.json:/config.json # Set to use config to get credentials to pull from private repository
    command: --interval 60 # Run every minute


I mentioned that I have domain connected to my VPS. It's an understandable desire nowadays to make it work via HTTPS protocol as well as via HTTP.

Traefik is a powerful reverse proxy and load balancer tool, but all we need from Traefik here is its support for Let's Encrypt.

Let's sum up our intentions. We need to run Traefik as a Docker container on our server machine, configure it to redirect to Nginx inside the blog container, and resolve SSL certificates automatically.

Create a Docker proxy network. It will be used by our other containers to be in the same network as Traefik, so Traefik could proxy to them.

docker network create proxy

Create traefik directory anywhere on the server machine and data directory inside it. It's not required to make these directories structure like that, but my snippets below imply this structure and naming.

mkdir traefik
mkdir traefik/data

Create empty acme.json file inside data directory. It will be filled later by Traefik itself when it get certificates data.

touch traefik/data/acme.json

Create traefik.yml configuration inside data directory.

nano traefik/data/traefik.yml

Fill traefik.yml.

  dashboard: true

    address: ":80"
    address: ":443"

    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false

      email: # your email address
      storage: acme.json # link for acme.json file to write into
        entryPoint: http

Create Traefik dashboard credentials. Fill user and password, save result string for later use.

echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g

Create docker-compose.yml for Traefik image in the traefik directory.

nano traefik/docker-compose.yml

Fill docker-compose.yml.

version: '3'

    image: traefik:v2.0 # Traefik image to use
    container_name: traefik # Container name to run image
    restart: unless-stopped # Set traefik container to restart on exit or Docker restarts
      - no-new-privileges:true
      - proxy # Set traefik to use docker network we set up earlier
      - 80:80 # HTTP port mapping
      - 443:443 # HTTPS port mapping
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./data/traefik.yml:/traefik.yml:ro # Traefik configuration file
      - ./data/acme.json:/acme.json # Traefik certificates file
      - "traefik.enable=true"
      - "" # Set traefik to use docker network we set up earlier
      - "traefik.http.routers.traefik.entrypoints=http"
      - "traefik.http.routers.traefik.rule=Host(``)" # Set your domain here
      - "traefik.http.middlewares.traefik-auth.basicauth.users=username:password" # Set traefik dashboard credentials we got earlier
      - "traefik.http.middlewares.traefik-https-redirect.redirectscheme.scheme=https"
      - "traefik.http.routers.traefik.middlewares=traefik-https-redirect"
      - "traefik.http.routers.traefik-secure.entrypoints=https"
      - "traefik.http.routers.traefik-secure.rule=Host(``)" # Set your domain here
      - "traefik.http.routers.traefik-secure.middlewares=traefik-auth" # Set traefik dashboard to be secured with auth
      - "traefik.http.routers.traefik-secure.tls=true"
      - "traefik.http.routers.traefik-secure.tls.certresolver=http"
      - "traefik.http.routers.traefik-secure.service=api@internal"

  proxy: # Define external network to run traefik with
    external: true

Add permission to write into traefik/data/acme.json file. It's used to write Let's Encrypt certificates information.

chmod 600 acme.json

Run Docker Compose from the traefik directory.

cd traefik
docker-compose up -d

Blog config

Now we have everything configured. Our blog image is the one thing we didn't create config for.

Result docker-compose.yml for the project looks like this.

version: "3"

  web: # My blog image configuration
    image: # docker image location in GHCR
    restart: unless-stopped
      - "traefik.enable=true"
      - "traefik.http.routers.web.entrypoints=http"
      - "traefik.http.routers.web.rule=Host(``)" # Your domain
      - "traefik.http.middlewares.web-https-redirect.redirectscheme.scheme=https"
      - "traefik.http.routers.web.middlewares=web-https-redirect"
      - "traefik.http.routers.web-secure.entrypoints=https"
      - "traefik.http.routers.web-secure.rule=Host(``)" # Your domain
      - "traefik.http.routers.web-secure.tls=true"
      - "traefik.http.routers.web-secure.service=web"
      - ""
      - proxy
  watchtower: # Watchtower configuration I described earlier
    image: containrrr/watchtower
    restart: unless-stopped
      - /var/run/docker.sock:/var/run/docker.sock
      - /root/.docker/config.json:/config.json
    command: --interval 60

  proxy: # Set external Docker network to use
    external: true

Now we can start it.

docker-compose up -d


As result, I have:

  • Dockerfile to build a blog image from.
  • Traefik docker-compose.yml with its configuration traefik.yml and acme.json.
  • Main docker-compose.yml to run a blog with Watchtower listening to its changes.

These files can be stored in the same repository for later use (just copy-paste from repo to remote VPS when configuring). The main bonus we got by using Docker is how easy we can bootstrap same configuration on any other machine.

TLDR setup blog from nothing

  • Register domain
  • Create VPS
  • Bind VPS with domain
  • Create repository with blog on Github
    • If repository is private there is Secret setup needed in repo Settings.
    • Create Github Action to publish blog Docker image into Github Container Registry
  • ssh into VPS
    • Install Docker and Compose
    • Create a proxy network using Docker
    • If repository is private there is GHCR auth needed
    • Copy configs for Traefik to VPS (from above)
    • Run Traefik compose
    • Copy compose config for blog to VPS (from above)
    • Run blog compose

That's all. All I need to do now to add a new post is to write it and push to the repository. All deployment stuff is automatic.

Isn't it clean enough? I think so.