CI Pipeline for FFlags.com

This is a write-up of the initial CI pipeline I set up for FFlags.com. With fflags.com I wanted to keep things clean and simple as much as possible. The pipeline is built using Github Actions, GHCR, and Railway.

The Stack

The React frontend is built and embedded into the Golang binary using go:embed.

The Pipeline

The pipeline is triggered on a new release which builds the docker image, pushes it to GHCR. The workflow file looks something like this:

# release.yml
name: Release Workflow

on:
  release:
    types: [created]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "20"

      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Build dashboard app
        working-directory: ./dashboard
        run: |
          pnpm install
          pnpm run build

      - name: Setup Go
        uses: actions/setup-go@v4
        with:
          go-version: "1.23"

      - name: Build Go binary
        run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags '-extldflags "-static"' -o bin/fflags cmd/web-server/main.go

      - name: Log in to the Container registry
        uses: docker/login-action@v2
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

The pipeline builds the React frontend, the Golang binary, and pushes the docker image to GHCR. The Dockerfile for the Golang server looks like this:

# Dockerfile
FROM alpine:latest

WORKDIR /app
COPY --chmod=0755 bin/fflags /app/

EXPOSE 8081

CMD ["/app/fflags"]

Mistakes and Learnings

Initially, I had the following build command for the Go executable:

go build -o bin/fflags cmd/web-server/main.go

This command worked fine on my local machine and on the CI. But, the binary was not executing on the alpine image and throwing the following error:

exec /app/fflags: no such file or directory

The problem here was I was building the binary on a Debian-based OS and trying to run it on an Alpine-based image. The solution was to build the binary with the following command:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags '-extldflags "-static"' -o bin/fflags cmd/web-server/main.go

Here, CGO_ENABLED=0 disables the use of cgo which is required to build a static binary. GOOS=linux and GOARCH=amd64 specify the target OS and architecture respectively. and -ldflags '-extldflags "-static"' is used to link the binary statically.

Deploying to Railway

After the docker image is pushed to GHCR, I manually deploy the image to Railway. I am planning to automate this step as well in the future when the application becomes a little stable.