Setting up a CI/CD pipeline for publishing blog posts

Setting up a CI/CD pipeline for publishing blog posts

The goal of this project is to create a local ci/cd pipeline that automatically builds and deploys a static site. I also wanted to use opensource tools and steer clear of tools that may have a free tier but charge later. To do this I used Gitea as my git repo as well as ci/cd server.

Overview

Here is the overview of the pipeline diagram of cicd pipeline

Gitea Server

Gitea is an open source git server similar to gitlab and github that can be self hosted. It also has runner features and compatibility with github actions. It needs a database as well as the appliation server to run so we will also be setting up a postgres container.

Docker Compose

This docker compose has the postgres and gitea server in one. Make sure to store the password to the database in the local file specified under the secrets: section. I am also using dockers secret option which allows you to store secrets in a file instead of an environment variable or as plaintext in your docker compose. Also I am using MACVLAN networking for this compose because that is how my current development network is set up but you can also use the default bridge networking and map the ports to your docker host. You can see an example docker compose in the gitea documentation

version: "3"

services:
  gitea:
    image: gitea/gitea:1.22.3
    container_name: gitea
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=gitea_postgres:5432
      - GITEA__database__NAME=giteadb
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD__FILE=/run/secrets/gitea_db_secret
    restart: always
    networks:
      lanmacvlan:
        ipv4_address: 192.168.0.133
    volumes:
      - /data/docker/gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    depends_on:
      - gitea_postgres

  gitea_postgres:
    image: postgres:14
    restart: always
    environment:
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD_FILE=/run/secrets/gitea_db_secret
      - POSTGRES_DB=gitea
    networks:
      lanmacvlan:
        ipv4_address: 192.168.0.134
    volumes:
      - /data/docker/gitea/postgres/data:/var/lib/postgresql/data
    secrets:
      - gitea_db_secret

networks:
  lanmacvlan:
    driver: macvlan
    driver_opts:
      parent: br0
    ipam:
      config:
        - subnet: "192.168.0.0/22"
          gateway: "192.168.0.1"
          ip_range: "192.168.0.128/25"
          aux_addresses:
            hostmacvlan: "192.168.0.254"

secrets:
  gitea_db_secret:
    file: gitea_db.pwd

Initialize postgres database

Make sure /data/docker/gitea/postgres/data exists and has permission for 1000:1000 to access. Then create the database for gitea to use.

docker compose up -d gitea_postgres
docker exec -it gitea_postgres bash
psql -U gitea -W
CREATE DATABASE giteadb WITH OWNER gitea TEMPLATE template0 ENCODING UTF8 LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8';

A better approach than manually creating the database would be to use postgres_initdb_args and postgres_host_auth_method for initialization. I ran into trouble getting the database creation script working so I have just done it manually for now.

Configure postgres

Create the configuration for postgres to listen on the IP of your docker host or container depending on how you set up your networking. This tells postgres which interfaces to listen on. /data/docker/gitea/postgres/data/postgresql.conf

listen_addresses = 'localhost, 192.168.0.134'
password_encryption = scram-sha-256

pg_hba.conf tells what users are allowed from what hosts to authenticate to the database. I have added just the gitea container using the gitea user and gitea database because that is all I’m using this postgres container for.

/data/docker/gitea/postgres/data/pg_hba.conf

host    giteadb    gitea    192.168.0.133/32    scram-sha-256

Start gitea

You can now run the full docker compose which will also start the gitea server. You should see the initial setup screen on port 3000

Setup Gitea Runner

First generate a registration token. For an instance wide runner it is under /admin/actions/runners. Put the gitea variables like INSTANCE_URL and REGISTRATION_TOKEN in a file called .env in the working directory where your docker compose file is. I wanted to use a newer version of node so this is what I have for my runner labels:

RUNNER_LABELS="ubuntu-latest:docker://ubuntu:latest,ubuntu-22.04:docker://ubuntu:jammy,ubuntu-20.04:docker://ubuntu:focal,ubuntu-18.04:docker://ubuntu:bionic,node20-bullseye:docker://node:20-bullseye"

Pull the Gitea act runner image and generate a config

docker pull gitea/act_runner:latest # for the latest stable release
docker run --entrypoint="" --rm -it gitea/act_runner:latest act_runner generate-config > config.yaml

Edit any settings in the default config.yaml configuration that you want to change. I only needed to change the network. Make sure you use the name generated by docker compose for the network name which may be different than what it is named in your docker compose file. You can see it with docker network list

Finally, add this service to the docker compose and start the runner.

version: "3.8"
services:
  gitea_runner:
    image: gitea/act_runner:latest
    environment:
      CONFIG_FILE: /config.yaml
      GITEA_INSTANCE_URL: "${INSTANCE_URL}"
      GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
      GITEA_RUNNER_NAME: "${RUNNER_NAME}"
      GITEA_RUNNER_LABELS: "${RUNNER_LABELS}"
    networks:
      lanmacvlan:
        ipv4_address: 192.168.0.135
    volumes:
      - ./config.yaml:/config.yaml
      - /data/docker/gitea_node_runner:/data
      - /var/run/docker.sock:/var/run/docker.sock

If everything went well you should see the runner show up in gitea with your name and tags

gitea runner admin interface showing newly created runner

Creating the Runner Action

The repo in Gitea will have another branch called preview which will be used to push new website changes to a separate preview page in cloudflare pages. When the changes from the preview branch are merged to main, another Gitea action will build the site and push to the production cloudflare page.

Add cloudflare API secret and account ID in gitea

In cloudflare go to Manage Account - Account API tokens then Create Token Select Use Template next to Edit Cloudflare Workers gitea runner admin interface showing newly created runner Select all zones on the account and generate the token

Add token to Gitea secrets

In Gitea go to Settings - Actions - Secrets and Add Secret and add the token above. Then create another secret with the Account ID which can be found here

Setup Cloudflare Pages

First we will need to create a cloudflare pages project and deploy it with direct upload.

pnpm create cloudflare@latest

You can select No when asked if you want to deploy. Now you will have a wrangler.toml file that you can copy over to your root of the project directory.

If you do not already have an existing cloudflare pages project then deploy one now so it is created

pnpm install wrangler
pnpm wrangler login
pnpm run build
pnpm wrangler pages deploy

Create Gitea Actions Workflow

Create a new workflow in the root of the project directory .gitea/workflows/deploy.yaml

name: Build AstroJS and Deploy to Cloudflare

on:
  push:
    branches: [main, preview]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout your repository using git
        uses: actions/checkout@v4
      - name: Install, and build astro site
        uses: ./.gitea/actions/withastro
        # with:
            # path: . # The root location of your Astro project inside the repository. (optional)
            # node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 18. (optional)
            # package-manager: pnpm@latest # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
      - name: Deploy to cloudflare pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLAREWORKERS }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy

Next create the file .gitea/actions/withastro/action.yml This is a copy of the official github action by AstroJS but taking the last part out where it deploys to github pages. This is also useful because it sets up the node environment which will be needed for wrangler to deploy to Cloudflare.

name: "Astro Deploy"
description: "A composite action that prepares your Astro site to be deployed to GitHub Pages"
branding:
  icon: "arrow-up-right"
  color: "purple"
inputs:
  node-version:
    description: "The node version to use"
    required: false
    default: "20"
  package-manager:
    description: "You may specify your preferred package manager (one of `npm | yarn | pnpm | bun` and an optional `@<version>` tag). Otherwise, the package manager will be automatically detected."
    required: false
    default: ""
  path:
    description: "Path of the directory containing your site"
    required: false
    default: "."

runs:
  using: composite
  steps:
    - name: Check lockfiles
      shell: "bash"
      working-directory: ${{ inputs.path }}
      env:
        INPUT_PM: ${{ inputs.package-manager }}
      run: |
        len=`echo $INPUT_PM | wc -c`
        if [ $len -gt 1 ]; then
          PACKAGE_MANAGER=$(echo "$INPUT_PM" | grep -o '^[^@]*')
          VERSION=$(echo "$INPUT_PM" | { grep -o '@.*' || true; } | sed 's/^@//')
          # Set default VERSION if not provided
          if [ -z "$VERSION" ]; then
              VERSION="latest"
          fi
          echo "PACKAGE_MANAGER=$PACKAGE_MANAGER" >> $GITHUB_ENV
        elif [ $(find "." -maxdepth 1 -name "pnpm-lock.yaml") ]; then
            echo "PACKAGE_MANAGER=pnpm" >> $GITHUB_ENV
            echo "LOCKFILE=pnpm-lock.yaml" >> $GITHUB_ENV
        elif [ $(find "." -maxdepth 1 -name "yarn.lock") ]; then
            echo "PACKAGE_MANAGER=yarn" >> $GITHUB_ENV
            echo "LOCKFILE=yarn.lock" >> $GITHUB_ENV
        elif [ $(find "." -maxdepth 1 -name "package-lock.json") ]; then
            VERSION="latest"
            echo "PACKAGE_MANAGER=npm" >> $GITHUB_ENV
            echo "LOCKFILE=package-lock.json" >> $GITHUB_ENV
        elif [ $(find "." -maxdepth 1 -name "bun.lockb") ]; then
            VERSION="latest"
            echo "PACKAGE_MANAGER=bun" >> $GITHUB_ENV
            echo "LOCKFILE=bun.lockb" >> $GITHUB_ENV
        else
            echo "No lockfile found.
        Please specify your preferred \"package-manager\" in the action configuration."
            exit 1
        fi
        echo "VERSION=$VERSION" >> $GITHUB_ENV
    - name: Setup PNPM
      if: ${{ env.PACKAGE_MANAGER == 'pnpm' }}
      uses: pnpm/action-setup@v4
      with:
        version: ${{ env.VERSION }}
        package_json_file: "${{ inputs.path }}/package.json"
    
    - name: Setup Bun
      if: ${{ env.PACKAGE_MANAGER == 'bun' }}
      uses: oven-sh/setup-bun@v1
      with:
        bun-version: ${{ env.VERSION }}

    - name: Setup Node
      uses: actions/setup-node@v4
      if: ${{ env.PACKAGE_MANAGER != 'bun' }}
      with:
        node-version: ${{ inputs.node-version }}
        cache: ${{ env.PACKAGE_MANAGER }}
        cache-dependency-path: "${{ inputs.path }}/${{ env.LOCKFILE }}"
    
    - name: Setup Node (Bun)
      uses: actions/setup-node@v4
      if: ${{ env.PACKAGE_MANAGER == 'bun' }}
      with:
        node-version: ${{ inputs.node-version }}

    - name: Install
      shell: "bash"
      working-directory: ${{ inputs.path }}
      run: $PACKAGE_MANAGER install

    - name: Build
      shell: "bash"
      working-directory: ${{ inputs.path }}
      run: $PACKAGE_MANAGER run build

Troubleshooting

Github action

Error: No pnpm version is specified. Add packageManager to package.json with the version of pnpm that you are using.

{
  "name": "",
  "type": "module",
  "version": "0.0.1",
  "packageManager": "[email protected]"

Testing

Setting up local env for testing astrojs site

curl -fsSL https://get.pnpm.io/install.sh | sh -
pnpm env use --global lts
rename .env.example to .env
pnpm run dev