cc by-sa flurdy

Migrate blog to Jekyll

(with CircleCI & Kubernetes)

Migrating my own blog from Blogger to a Jekyll generated blog.

Started: February 2019. Last updated: 22nd Feb 2019.


I posted two blog posts recently: The first post was about that I am moving my blog away from Blogger, and the reasons why. And the second post was that I was moving it to a Jekyll based blog, and specifying briefly why and how.


This howto is to show how it was done in detail. And not why as that is covered in the blog posts.

This howto will show you how to create a Jekyll site using a Docker container. How to migrate blog posts from Blogger to Jekyll. What theme I chose. How I build my blog with CircleCI, and how to host it with Kubernetes.

My blog is at and the repository is at


Jekyll is web site generator. It is written in Ruby and was created by GitHub to power their GitHub pages sites.

It outputs static html pages that you can host with any webserver.

Create Jekyll site

Jekyll is normally installed as a Ruby Gem. I do not want Ruby installed locally, so thankfully there is a good Jekyll Docker image that lets you do all the development inside the container.

Lets launch the container with a bash prompt, that mounts our local folder as /srv/jekyll within the container:

docker run --rm --volume="$PWD:/srv/jekyll" -it jekyll/jekyll:3.8 /bin/bash

And inside the container create a basic site and exit which destroys the container. However all the files are retained in the mounted volume folder.

jekyll new myblog

Initialise a Git repository inside the folder and launch a container now exposing a port mapping.

cd myblog
git init
docker run --rm --volume="$PWD:/srv/jekyll" -it -p 4000:4000 jekyll/jekyll:3.8 /bin/bash

Lets start the built in Jekyll web server from bash.

bundle exec jekyll bundler

Your plain website is now available at localhost:4000. (Assuming your docker host is local)

Edit _config.yml and other files to see how the changes are reflected.

Docker Compose

For ease of development I create a Docker Compose file to quickly launch the Jekyll server. And relaunch it quickly if tweaking the Gemfile or _config.yml which does not auto reload as opposed to most other files.

vi docker-compose.yml version: '3'

    image: jekyll/jekyll:3.8
    container_name: blog
      - JEKYLL_ENV=docker
    command: jekyll serve --force_polling --livereload --drafts
      - 4000:4000
      - 35729:35729
      - ./:/srv/jekyll
docker-compose up

Migrate old Blogger posts

Jekyll does come with a lot of plugins to migrate from other Blog and websites to Jekyll. The Blogger plugin works well.

First export your existing Blogger posts. Move the downloaded xml file into a _import folder for example.

Then run the import script from within a Ruby container, a Jekyll one will do:

ruby -r rubygems -e 'require "jekyll-import";{
      "source" => "_include/blog-MM-DD-YYYY.xml",
      "no-blogger-info" => false,
      "replace-internal-link" => false,

This will create all your posts as files within _posts.

In Blogger each post of mine had a year and month prefix folder path, but by default Jekyll also each post in day of the month subfolder as well. To main the same file names I needed to tweak Jekyll's permalink configuration.

vi _config.yml permalink: '/:year/:month/:title:output_ext'


There are a lot of Jekyll themes out there. Lost of free ones, quite an extensive amount of themes for sale between $25-$50. Some are Ruby Gem based which makes installation a breeze.

There was a few paid ones that looked good, but I find it hard to pay for a theme when I am not sure I would end up using it. However they still got to eat and pay rent so I don't know a better monitisation for them. I guess a post pay if you like has a lot lower return rate than prepay.

Not all are suitable for a blog based websites. And I had further requirements such as a good sidebar, built in support for tags. In the end I settled on the excellent Galada theme by Artem Sheludko. It is MIT licensed.

I modified the Galada theme a little bit, such as adding a white background to blog posts for easier reading. I added an avatar vanity part to the sidebar. For easier browsing to old posts I added archive links in the sidebar, and archive templates. And I added a common License template for the blog posts content.

To support these changes I added these plugins to the _config.yml and Gemfile files:

vi _config.yml plugins:
  - jekyll-feed
  - jekyll-paginate
  - jekyll-sitemap
  - jekyll-archives
vi Gemfile group :jekyll_plugins do
  gem "jekyll-paginate"
  gem "jekyll-sitemap"
  gem "jekyll-archives"
  gem "jekyll-feed", "~> 0.6"

CI Build

The beauty of Jekyll is that output is static html files and assets in the _site folder. And that folder can be copied to any webserver and that is all you need.

This set up uses CircleCI as the CI build tool. It will generate the _site folder, create a Docker image, and publish that image to a Docker registry. For this howto the registry used is

For this project I create a GitHub repository that I push each change to. On CircleCI I then added a new build based on this Git repository.


Two generate a Docker image to deploy the blog site anywhere, a very simple Dockerfile based on Nginx image will do.

vi Dockerfile FROM nginx:1.15-alpine

COPY _site /usr/share/nginx/html

Generate _site

To generate the _site these steps are required. (We also unfortunately need to hack a bundler reinstallation step.)

mkdir .circleci
vi .circleci/config.yml
version: 2.1
      - image: circleci/ruby:2.6.0-node
      BUNDLE_PATH: ~/repo/vendor/bundle
      BUNDLE_VERSION: 2.0.1
    working_directory: ~/repo
      - checkout
      - restore_cache:
            - v1-dependencies-{{ checksum "Gemfile.lock" }}
            - v1-dependencies-
      - run:
          name: install bundler
          command: |
            sudo gem update --system
            sudo gem uninstall bundler
            sudo rm /usr/local/bin/bundle
            sudo rm /usr/local/bin/bundler
            sudo gem install bundler
      - run:
          name: install dependencies
          command: |
            bundle check || bundle install
      - save_cache:
            - ./vendor/bundle
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
      - run:
          name: Jekyll build
          command: bundle exec jekyll build
      - persist_to_workspace:
          root: ./
            - _site

Build docker image

Building the Docker image is relatively straight forward. We also save the image as a tar file to be reused in the later publish step.

    - image: circleci/buildpack-deps:stretch
    - checkout
    - attach_workspace:
        at: ./
    - setup_remote_docker
    - run:
        name: Build Docker image
        command: docker build -t $IMAGE_NAME:latest .
    - run:
        name: Archive Docker image
        command: docker save -o image.tar $IMAGE_NAME
    - persist_to_workspace:
        root: .
          - ./image.tar

Docker registry

Before you upload any Docker images anywhere you need to create a Docker registry for it to upload it.

For this howto we create a repository. And a robot user with write permissions to authenticate with.

Publish Docker image

To upload the Docker image to the registry a few run command is needed. Also you need to add into CircleCI's project settings two environment variables: DOCKER_LOGIN and DOCKER_PASSWORD.

From these are from the robot user authentication details.

    - image: circleci/buildpack-deps:stretch
    - setup_remote_docker
    - attach_workspace:
        at: ./
    - run:
        name: Load archived Docker image
        command: docker load -i image.tar
    - run:
        name: Upload to registry
        command: |
          echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_LOGIN" --password-stdin
          docker push $IMAGE_NAME:latest
          docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
          docker push $IMAGE_NAME:$IMAGE_TAG

For each Git push that triggers a CircleCI build you should now also have Docker images in


You can host a Jekyll site in so many ways. You can use AWS S3, GitHub Pages and may others.

I prefer to choose my own Kubernetes solution. That way it can use any plugins it wants and is not tied to a service provider.

You can choose between so many Kubernetes providers. Your own Kubernetes the-hard-way set up, using Google's GKE, using Amazon's EKS, using Microsoft Azure's AKS, etc.

I chose Digital Ocean's recent Kubernetes solution.

Digital Ocean

In Digital Ocean's console I create a new Kubernetes cluster. I add one node to it.

I then download the kubectl config to authenticate with the cluster. I make sure this works by getting the cluster information:

kubectl cluser-info

Kubernetes secret

To enable my K8s setup to download images from Quay I need to create a secret.

First I go into's console for my docker registry to add another robot user, this time with only read permissions.

Then I create the secret with:

kubectl create secret docker-registry myblog-registry \
--docker-username:MYQUAYROBOT --docker-password=MYQUAYROBOTPASSWORD

Kubernetes deployment

A simple deployment that refers to the stored image and secret.

mkdir kube
vi kube/deployment.yml
apiVersion: apps/v1
kind: Deployment
  name: myblog-deployment
      app: myblog
  replicas: 2
        app: myblog
        - name: myblog-container
            - containerPort: 80
        - name: myblog-registry

Note the tag 1.0.2. That must be which ever tag the latest Docker image is with the registry. An alternative is to use latest, but it is a bit arbitraty to which latest is currently deployed where then...

You then apply it to K8s:

kubectl apply -f kube/deployment.yml

And take a look at it with either:

kubectl get deployments kubectl describe deployments myblog-deployment

Kubernetes service

We also need a service to expose the deployment. You can go direct with a NodePort type of service, but I use Load balancer that uses Digital Ocean's cloud controller.

It is relatively simple:

vi kube/service.yml apiVersion: v1
kind: Service
  name: myblog-service
  type: LoadBalancer
    app: myblog
    - protocol: TCP
      port: 80
      targetPort: 80

You then apply the service to K8s:

kubectl apply -f kube/service.yml

And then keep an eye on it until it receives an external IP:

kubectl get service --watch kubectl describe service myblog-service

When it has an external IP, then that is it. The blog is live. Just add the IP to a DNS entry somewhere.

Blog posts procedure

I draft blog posts using markdown in the _drafts folder. I do not prefix with a date. Locally the Docker Compose set up will pick the drafts up as blog posts as it has the --drafts argument.

mkdir _drafts
vi _drafts/
# A new blog!

Blah blah

When the draft post is ready to be published, I move it to the _posts folder and prefix with a date.

mv _drafts/ _posts/

I commit this to git and push to the origin repository. CirceCI starts a build, I get notified of the new docker image tag by I then update the tag number in the K8s deployment.yml file and reapply it to active the blog post on the live site:

vi kube/deployment.yml ...
kubectl apply -f kube/deployment.yml

Ps. I also temporarily move most posts out of _posts whilst developing for a faster site generation feedback loop.

Continuous deployment

A later addition to my blog set up is to automatically deploy my blog images to Kubernetes. I.e. a type of continuous deployment.

For this I use Keel. Keel lets you set labels that decide how a deployment container is updated.

I installed Keel via Helm. Please read my Kubernetes Ingress & TLS with Helm doc on how to install Helm.

I then modified my blog depoyment.yml with:

vi kube/deployment.yml ...
  name: myblog-deployment
  labels: major poll
  annotations: "@every 10m"
kubectl apply -f kube/deployment.yml

So whenever a new Semver tag of my blog image is uploaded to my registry (by CircleCI), Keel updates my Kubernetes deployment.


Please fork and send a pull request for to correct any typos, or useful additions.

Buy a t-shirt if you found this guide useful. Hire me for short term advice or long term consultancy.

Otherwise contact me. Especially for things factually incorrect. Apologies for procrastinated replies.