Host your blog in a Cloud VM using Docker

A summary of my experience migrating this blog from Kamal to Docker

February 28, 2025

#docker #kamal

Since I started this blog, I hosted it on a Cloud VM using Basecamp’s kamal. Last year, Basecamp released kamal 2. kamal 2 is slightly backwards incompatible with kamal 1. When a kamal 1 command I ran failed, I decided to just upgrade to kamal 2 (instead of investing time to investigate a failure in the older kamal 1).

kamal 1 to 2 upgrade experience

I modified kamal configuration file (so it became compatible with kamal 2), created the .kamal/secrets file and ran the kamal upgrade command. The upgrade failed failed because my blog’s Docker container failed kamal’s health check https://gist.github.com/nisanthchunduru/dc908ac676454c18adb64837bf204aeb Once I resolved the problem by adding a HEALTHCHECK directive to my blog’s Dockerfile, kamal upgrade succeeded.

Unfortunately, kamal deploy next failed with this error message

...
INFO [f6b4836f] Running /usr/bin/env git -C /var/folders/q3/0jl1k3mx143fw8xbs4m8qp0w0000gq/T/kamal-clones/blog-f5d00af52b8b7/blog/ status --porcelain as chunisan@localhost
INFO [f6b4836f] Finished in 0.038 seconds with exit status 0 (successful).
INFO [98e6f00b] Running /usr/bin/env git -C /var/folders/q3/0jl1k3mx143fw8xbs4m8qp0w0000gq/T/kamal-clones/blog-f5d00af52b8b7/blog/ rev-parse HEAD as chunisan@localhost        
INFO [98e6f00b] Finished in 0.031 seconds with exit status 0 (successful).        
Finished all in 8.5 second
ERROR (NoMethodError): undefined method `gsub' for true

The method gsub is a method that all strings in the Ruby programming language have. A variable that kamal 2 expected to a string seems to be a boolean with the value true.

Pivoting to Docker

While I’ve extensive experience with Ruby and will likely be able to resolve the problem, I wondered if it may far easier to just start Docker container/containers myself (especially given the app I was trying to host is just a blog).


To my pleasant surprise, I found that it was very easy to start a Docker container. I ran the command below to start my blog in a Docker container

mkdir -p /opt/blog-content/
docker run -d -p 80:1313 -v /opt/blog-content:/opt/blog/content --restart unless-stopped nisanth074/blog:latest

and that one command brought my blog online and made it publicly on http://

HTTPS

To make my blog available on https://, I turned on Cloudflare’s Proxy feature for my blog domain’s DNS A record in Cloudflare’s dashboard. Doing that causes Cloudflare to issue an SSL certificate and serve my blog over https. While the traffic between Cloudflare and my blog is still unencrypted, that’s an acceptable tradeoff for me.

If you don’t use Cloudflare or dislike the approach above, you can easily install and configure Caddy. Caddy will automatically obtain an SSL certificate for your blog’s domain and serve it over https://

Ancillary Docker containers

As I write my blog posts in Notion, I also have to run hugo-notion (a CLI tool that I developed in Go to sync blog posts from Notion to Hugo’s content dir) in a different Docker container.

Fortunately, starting ancillary docker containers is straightforward.

mkdir -p /opt/blog

touch /opt/blog/.env
echo "CONTENT_NOTION_URL=https://www.notion.so/blog_content-0f1b55769779411a95df1ee9b4b070c9" >> /opt/blog/.env
echo "NOTION_TOKEN=my_notion_access_token" >> /opt/blog/.env
echo "GOPROXY=direct" >> /opt/blog/.env

docker run -d -v /opt/blog-content:/opt/blog/content --env-file /opt/blog/.env --restart unless-stopped nisanth074/hugo-notion 

Ease of use

To restart/update my blog and hugo-notion with ease, I simply added a docker-compose.production.yml file

services:
  sinatra:
    image: nisanth074/blog:latest
    volumes:
      - /opt/blog-content:/opt/blog/content/
    ports:
      - "80:4567"
    restart: unless-stopped
  hugo-notion:
    image: nisanth074/hugo-notion
    volumes:
      - /opt/blog-content:/opt/blog/content
    env_file: /opt/blog/.env
    working_dir: /opt/blog

and then added a deploy.sh bash script to copy the docker compose file to my CloudVM and to restart Docker containers

npm run docker:push:x86
scp .env root@1.2.3.4:/opt/blog/.env
scp docker-compose.production.yml root@150.136.2.200:/opt/blog/docker-compose.yml
ssh root@150.136.2.200 "cd /opt/blog && docker compose down"
ssh root@150.136.2.200 "cd /opt/blog && docker compose up -d --pull always"

Takeaway

This move re-inforced my opinion that every abstraction introduced to a project (kamal in this scenario) should bring value that significantly surpasses the learning curve and complexity it’d add. Otherwise, that abstraction should be discarded. While kamal is a great alternative to Kubernetes, its a total overkill for hosting simple projects (like this blog).


I hope this post helps you host your blog or other hobby projects using Docker.

Source Code