I wanted to try out Writebook but I ran into 3 major problems:

  • The authors require you to download an arbitrary script off the internet and run it on your machine. Now, 37Signals is an extremely reputable company but there are really a few people that will randomly execute a script downloaded online.

  • The script enforces one app per machine. You cannot run any other small apps on the machine alongside Writebook. I already own a small VPS that hosts Fotion and TewoTewo while using Caddy as a proxy in front of them. I want to do the same for Writebook. But even after deciding to run the script, it failed because of this requirement. Now, I am not running a serious Writebook instance, so why would I want yet another 2GB server on my monthly bill?

  • Calls home. My understanding is every other night, Writebook pings back home to check for updates and replaces them as-is. Personally, I'm not a fan of that.

Luckily, they provide the source code after your download. With that, I was able to build out a simplistic process that runs Writebook alongside other projects on the same VPS and all served by Caddy.

My main tools are:

  • Github ( private repository )
  • Github actions
  • An existing server obviously
  • Ansible or a way to ssh into the server manually

Installing Docker

Since I already use Ansible to manage my configuration, here is what my docker creation config looks like:

If you prefer to ssh into the server and do all that manually, install docker and run docker login

---
- name: Install Docker
  hosts:
    - infra
    - main
  become: true
  vars:
    arch_mapping:
      x86_64: amd64
      aarch64: arm64

  tasks:
    - name: Update and upgrade all packages to the latest version
      ansible.builtin.apt:
        update_cache: true
        upgrade: dist
        cache_valid_time: 3600

    - name: Install required packages
      ansible.builtin.apt:
        pkg:
          - apt-transport-https
          - ca-certificates
          - curl
          - gnupg
          - software-properties-common

    - name: Create directory for Docker's GPG key
      ansible.builtin.file:
        path: /etc/apt/keyrings
        state: directory
        mode: "0755"

    - name: Add Docker's official GPG key
      ansible.builtin.apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        keyring: /etc/apt/keyrings/docker.gpg
        state: present

    - name: Print architecture variables
      ansible.builtin.debug:
        msg: "Architecture: {{ ansible_architecture }}, Codename: {{ ansible_lsb.codename }}"

    - name: Add Docker repository
      ansible.builtin.apt_repository:
        repo: >-
          deb [arch={{ arch_mapping[ansible_architecture] | default(ansible_architecture) }}
          signed-by=/etc/apt/keyrings/docker.gpg]
          https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable
        filename: docker
        state: present

    - name: Install Docker and related packages
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
        update_cache: true
      loop:
        - docker-ce
        - docker-ce-cli
        - containerd.io
        - docker-buildx-plugin
        - docker-compose-plugin

    - name: Add Docker group
      ansible.builtin.group:
        name: docker
        state: present

    - name: Add user to Docker group
      ansible.builtin.user:
        name: "{{ ansible_user }}"
        groups: docker
        append: true

    - name: Enable and start Docker services
      ansible.builtin.systemd:
        name: "{{ item }}"
        enabled: true
        state: started
      loop:
        - docker.service
        - containerd.service

    - name: Log into ghcr.io
      docker_login:
        registry: ghcr.io
        username: github_username
        password: "{{ lookup('ansible.builtin.env', 'GH_TOKEN') }}"
        reauthorize: yes

  handlers:
    - name: Docker restart
      service: name=docker state=restarted enabled=yes

To run this, you need to retrieve your token from Github. Please make sure the token has the read:packages permission. This is because we need to configure the installer docker cli on the server to access the github packages repo.

export GH_TOKEN=YOUR_TOKEN
ansible-playbook -i ansible/inventory ansible/docker/install.yml

Ansible run for installing docker

Building docker image from the source code

Luckily, we have been provided a Dockerfile that does all the hardlifting. We just need to do two things:

  • Create a private repo on Github.
  • Add, commit, and push the files to Github
  • Write the Github actions workflow

In .github/workflows/build-image.yml:

on:
  push:
    branches:
      - main

name: Build Image
jobs:
  docker:
    runs-on: "ubuntu-latest"
    steps:
      - name: Get the version
        id: get_version
        run: echo ::set-output name=tag::$(echo ${GITHUB_SHA:8})

      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login into Docker
        uses: actions-hub/docker/login@master
        env:
          DOCKER_USERNAME: your_github_username
          DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
          DOCKER_REGISTRY_URL: ghcr.io

      - name: Build image
        run: docker build -t ghcr.io/${GITHUB_REPOSITORY}:${{ steps.get_version.outputs.tag }} .

      - name: Push
        uses: actions-hub/docker@master
        with:
          args: push ghcr.io/${GITHUB_REPOSITORY}:${{ steps.get_version.outputs.tag }}

  deploy_to_linode:
    name: Deploy to Server
    needs: [docker]
    runs-on: ubuntu-latest
    steps:
      - name: Get version
        id: get_version
        run: echo ::set-output name=tag::$(echo ${GITHUB_SHA:8})

      - name: Run new image
        uses: appleboy/ssh-[email protected]
        with:
          host: ${{ secrets.HOST }}
          username: root
          key: ${{ secrets.SSH_KEY }}
          script: >
            mkdir -p /root/writebook/storage/db &&
            mkdir -p /root/writebook/tmp &&
            docker stop $(docker ps -a | grep ghcr.io/${{ github.repository }} | awk '{print $1}') || true &&
            docker run -d -e SECRET_KEY_BASE=${{ secrets.SECRET_KEY_BASE }} -v /root/writebook/storage:/rails/storage -v /root/writebook/tmp:/rails/tmp -e DISABLE_SSL=true -p 4400:80 --user root ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.tag }}

In the above file, we build the docker image, push it to the Github package repository. Then ssh into the server and run the docker container ( exactly why we had to authorize into the server earlier ).

Notes
  • You must add a few secrets in the repository settings page as shown in the screenshot below. Use the actual values

    Secrets Github actions

  • You must have noticed I used --user root. The dockerfile creates a rails user that owns the process, but the issue now is that when you mount volumes, they will be unwritable because of permissions issues. Using --user root is acceptable for my usecase. It might not be for yours.

  • If --user root is not acceptable for your use case, consider installing Podman on your VPS and use it to run the docker image instead of the docker command. The --userns=keep-id for Podman might resolve this for you. So instead of --user root, something like --userns=keep-id --user $(id -u):$(id -g) might work for you.

Updating Caddy

As mentioned earlier, Caddy is really the heart of my setup. I have configured Caddy as below. I have two files majorly, the main.yml and the templates/caddyfile-main.j2

The main.yml file looks like this:

---
- hosts: main
  become: yes
  name: Install and Set up Caddy

  roles:
    - role: maxhoesel.caddy.caddy_server
      caddy_config_mode: "json"
      caddy_json_config: "{{ lookup('template', 'templates/caddyfile-main.j2') }}"

While the templates/caddyfile-main.j2 file looks like this:

{
  "logging": {
    "logs": {
      "": {
        "level": "error"
      }
    }
  },
  "apps": {
    "http": {
      "servers": {
        "api": {
          "listen": [":443"],
          "routes": [
            {
              "match": [
                {
                  "host": ["api.usefotion.app"]
                }
              ],
              "handle": [
                {
                  "handler": "reverse_proxy",
                  "upstreams": [
                    {
                      "dial": "localhost:4200"
                    }
                  ]
                }
              ]
            },
            {
              "match": [
                {
                  "host": ["api.tewotewo.com"]
                }
              ],
              "handle": [
                {
                  "handler": "reverse_proxy",
                  "upstreams": [
                    {
                      "dial": "localhost:4300"
                    }
                  ]
                }
              ]
            },
            {
              "match": [
                {
                  "host": ["notes.ayinke.ventures"]
                }
              ],
              "handle": [
                {
                  "handler": "reverse_proxy",
                  "upstreams": [
                    {
                      "dial": "localhost:4400"
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    }
  }
}

Extras

Two extra Github actions workflow that might make sense for you to implement are:

  • Weekly download of the source code, diff the content of the app/ and vendor/ directory, if files have changed, commit and push the changes so the build and deploy process can kick off

  • Daily backup of the sqlite3 file and upload to S3 or some other storage in case your VPS dies or something bad happens :).

Github actions support scheduling and cronjobs so this can be easily achieved. An untested example of the first one can look like this:

on:
  schedule:
    ## run at 12 pm every day
    - cron: "0 12 * * *"
  push:
    branches:
      - main

name: Download newer releases
jobs:
  docker:
    runs-on: "ubuntu-latest"
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install unzip
        run: sudo apt-get install unzip

      - name: Download and unzip file
        run: >
          wget -O downloaded https://auth.once.com/download/YOUR_TOKEN_FROM_EMAIL &&
          unzip downloaded &&
          mv downloaded/{app,bin,config,db,lib,script,vendor} ./

      - name: Commit and push changes
        uses: devops-infra/action-commit-push@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          commit_message: Update files