After attending a talk at That Conference about static sites, I decided to take the plunge and convert my personal website to a static website hosted in the cloud on a server I don't administrate or pay for.

What is a static website?

Wikipedia defines a static website as

one that has web pages stored on the server in the format that is sent to a client web browser.

In contrast, it defines a dynamic website as

one that changes or customizes itself frequently and automatically. Server-side dynamic pages are generated "on the fly" by computer code that produces the HTML (CSS are responsible for appearance and thus, are static files).

Basically, when a user views a static website, the server doesn't have to think - it simply responds with files that already exist on the server.

Why a static website?

Well I didn't intend for this post to be about the pros and cons of static vs. dynamic websites, so I'll leave you with the slideshow of the session at That Conference.

Static website engine

If you're familiar with static blogs, you know there are a bunch of programs for converting your text files into a static website. I decided to go with one called Cobalt for a few reasons. Cobalt is the first open source project I've ever contributed to. Way back in 2015 I submitted a pull request for adding a configuration file to it. As of this writing, a few lines of my commit still exist unchanged in the source code! Although I'll admit they're all either whitespace, brackets, or comments, hehe. Secondly, Cobalt is written in rust which is currently my favorite programming language.

Using a generator written in rust has a couple benefits. Rust is a very fast language because it's a systems language, so it's on par with C and C++ benchmarks. Also since I like the language, there's a good chance I'll be able to contribute more to the Cobalt project as I use it.

Converting from Ghost

I really like the Ghost blogging platform. My Azure Linux server hosts a few Ghost blogs for family. I love editing using markdown and its slimmed down feature list compared to something like Wordpress. Ghost allows you to export all your blog data as a json file. I then wrote a program in rust to deserialize the json and write all the posts out to .md files. I'll paste the source code at the bottom of this post in case anyone's interested. It's very naiive so I can't guarantee it will work for you, but it's easy to modify if needed.

After running my ghost-to-cobalt conversion program, there was still a lot of house cleaning to do. The URLs used by ghost are different than that of cobalt, I have to go through all links in every post to make sure they're still legitimate. As of this writing, I've not done that. The same can be true for images, but I was pretty diligent in using google photos to host my images, so 90% of those came over for free.


In Cobalt's readme it shows how to host a cobalt site with both GitHub Pages and GitLab Pages. I've been a fan of GitLab these days, so I decided to go that route (also, it seemed much easier than GitHub's version). It ended up being ridiculously simple. I made a repository on GitLab called '' and added this .gitlab-ci.yml file:

image: nott/cobalt:latest

  - mkdir -p public
  - cobalt build -d public
    - public/
  - master

This is all that's needed in the repository to create a site if cobalt runs successfully. After a few minutes of letting GitLab spin everything up, I was able to navigate to and see my site!

Conclusion & next steps

Overall everything went smoother than expected! Cobalt has been very nice to use and is easy to install since pretty much all my machines have rust and cargo installed. Hosting via GitLab was surprisingly easy even though Cobalt is not a popular static site generator. The reason it was so easy is because of the nott/cobalt docker container that exists out there.

Thanks for reading!

Oh, also, here is the code I wrote for converting my ghost blog.

use json::JsonValue;
use std::io::prelude::*;
use std::io::Result;
use std::fs::File;
use chrono::NaiveDateTime;

static JSON_FILE_LOCATION: &'static str = "~/ethans-blog.ghost.2017-07-20.json";

fn main() {
    let json_string = match get_file_contents(JSON_FILE_LOCATION) {
        Ok(s) => s,
        Err(e) => panic!("Problem accessing file at '{}'!\n\nerror:\n{:?}", JSON_FILE_LOCATION, e)

    let json_value = match json::parse(&json_string) {
        Ok(j) => j,
        Err(_) => panic!("Problem parsing json data!")

    let posts = json_value["db"][0]["data"]["posts"].members().map(|p| Post::new(p)).collect::<Vec<_>>();

    for post in posts.iter() {
        // println!("file contents: {}", &post.get_file_contents());
        write_file(&post.get_file_name(), &post.get_file_contents());

    println!("posts.len(): {}", posts.len());

fn get_file_contents(path: &'static str) -> Result<String> {
    let mut f = try!(File::open(path));
    let mut contents = String::new();
    try!(f.read_to_string(&mut contents));

fn write_file(path: &String, contents: &String) -> Result<()> {
    let mut f = try!(File::create(path));

struct Post {
    markdown: String,
    published_at: NaiveDateTime,
    title: String

impl Post {
    fn new(json: &JsonValue) -> Post {
        let markdown = json["markdown"].as_str().unwrap().to_owned();
        let title = json["title"].as_str().unwrap().to_owned();
        let published_at = json["published_at"].as_str().unwrap();

        Post {
            markdown: markdown,
            title: title,
            //published_at: NaiveDateTime::from_timestamp(published_at_secs, published_at_nanos as u32)
            published_at: NaiveDateTime::parse_from_str(published_at, "%Y-%m-%d %H:%M:%S").expect("couldn't parse datetime")

    pub fn get_file_contents(&self) -> String {
        let formatted_date = self.published_at.format("%d %b %Y %H:%M:%S +0000").to_string();
        println!("formatted_date: {}", formatted_date);
        format!("extends: default.liquid\n\ntitle: \"{}\"\ndate: {}\n---\n{}",
        // format!("extends: posts.liquid\n\ntitle: {}\ndate: {}\n---\n{}",
                // "hello1",
                // formatted_date,
                // "hello3")

    pub fn get_file_name(&self) -> String {
        format!("../ethan-blog/posts/{}.md", self.title.to_lowercase().replace(" ", "-"))