The Long Road to Forgejo Actions: A Docker Build Journey
The premise seemed simple enough
I wanted to build a Docker image for my Hugo blog and push it to Forgejo’s container registry. Automated builds on every push to master. Standard CI/CD stuff that GitHub Actions handles without a second thought. How hard could it be with Forgejo Actions?
Turns out, pretty hard. Not because Forgejo Actions is bad, but because self-hosting your CI/CD means you’re responsible for everything. And I mean everything.
Starting with the obvious problems
First problem: my Forgejo Actions runner didn’t have Docker installed. Of course it didn’t. I’d registered it with a basic node:20-bullseye image because that’s what I needed for npm builds. Hugo’s PostCSS dependencies required Node.js, so I grabbed a Node image and called it good.
But now I needed Docker too. And Hugo. And git. And all the build tools for native npm modules.
The solution seemed obvious: use Docker-in-Docker. Run Docker inside a Docker container. It’s a thing people do, right?
The Docker-in-Docker rabbit hole
I tried docker:dind. Didn’t work. I tried buildah. Didn’t work. I tried kaniko. Also didn’t work. Each attempt came with its own special flavor of authentication errors, permission issues, or mysterious failures that made no sense.
At some point I said “Please, just make this easy” out loud to my terminal, as if that would help.
The turning point came when I decided to throw away the clever solutions and do it the dumb way: just install Docker CLI on a plain Ubuntu image and mount the host’s Docker socket. No fancy Docker-in-Docker, no special build tools, just a regular Ubuntu container with access to the real Docker daemon.
Building a custom runner image
So I created a new repository, docker-images, specifically for my custom Forgejo Actions runner. Ubuntu 22.04 base, Docker CLI, Node.js 20, Hugo Extended, and build essentials for native dependencies.
The Dockerfile used a multi-stage build to download Hugo cleanly and keep the final image reasonably sized. Around 725MB, which isn’t tiny, but it’s not terrible either for a kitchen-sink build environment.
# Build stage - download Hugo
FROM ubuntu:22.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive
ARG HUGO_VERSION=0.138.0
RUN apt-get update && apt-get install -y wget ca-certificates \
&& wget -O hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
&& dpkg-deb -x hugo.deb /hugo-extracted \
&& rm -rf /var/lib/apt/lists/* hugo.deb
# Runtime - install Docker CLI and Node.js
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget git ca-certificates gnupg lsb-release \
build-essential python3 \
&& rm -rf /var/lib/apt/lists/*
# Docker CLI
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \
&& rm -rf /var/lib/apt/lists/*
# Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& npm cache clean --force
COPY --from=builder /hugo-extracted/usr/local/bin/hugo /usr/local/bin/hugo
Registered the runner with the new image:
forgejo-runner register --no-interactive \
--instance https://git.idolthought.com \
--token <TOKEN> \
--name forgejo-runner \
--labels docker:docker://git.idolthought.com/invoke-systems/docker-images/forgejo-runner:latest
Updated the runner config to mount the Docker socket:
container:
options: "-v /var/run/docker.sock:/var/run/docker.sock"
This worked. Finally. Docker commands worked in the Actions workflow.
Then came the registry problems
With Docker working, I tried to push to Forgejo’s container registry. Got a 500 error. Turns out the LXC container running Forgejo was 100% full. All 973MB of it. I’d given it 1GB total when I set it up, which seemed reasonable at the time, but container images are not small.
Expanded the disk to 100GB with Proxmox:
pct resize 126 rootfs +99G
Problem solved. Except now I was getting authentication errors.
The authentication nightmare
I tried using GITHUB_TOKEN, the built-in secret that Forgejo Actions provides automatically. It worked for reading the repository but failed when trying to push packages with “unauthorized: reqPackageAccess” errors.
So I created a Personal Access Token with full package permissions and stored it as REGISTRY_USERNAME and REGISTRY_PASSWORD secrets. That should work, right?
Nope. Still got authentication errors in the workflow. But when I ran the exact same docker login command in my terminal with the same credentials, it worked perfectly.
I tried Docker’s official actions: docker/login-action, docker/build-push-action, docker/metadata-action. Beautiful, clean, exactly how GitHub Actions does it. Got 401 errors.
At this point I said “This is retarded” to my screen, which was not productive but felt appropriate.
The solution was permissions all along
Turns out, it wasn’t the authentication method. It wasn’t the workflow syntax. It wasn’t Docker vs manual commands.
It was permissions. Forgejo requires you to manually create the package namespace before CI can push to it. You can’t just authenticate and push to a new package path like you can with Docker Hub or GitHub Container Registry.
I had to manually push a test image first to create the namespace:
docker tag nginx:alpine git.idolthought.com/invoke-systems/invoke-wizard-blog:test
echo "<PAT>" | docker login git.idolthought.com -u <username> --password-stdin
docker push git.idolthought.com/invoke-systems/invoke-wizard-blog:test
After that, the workflow worked. The official Docker actions worked. Everything worked.
The final workflow
Here’s what actually works:
name: Build and Publish Docker Image
on:
push:
branches:
- master
- production
workflow_dispatch:
jobs:
build:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: git.idolthought.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.idolthought.com/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.idolthought.com/${{ github.repository }}:buildcache
cache-to: type=registry,ref=git.idolthought.com/${{ github.repository }}:buildcache,mode=max
Clean, uses official actions, includes build caching, automatically generates tags based on branch and commit SHA.
What I learned
Self-hosting CI/CD means you’re responsible for the entire stack. The runner environment, the available tools, the disk space, the permissions. There’s no “it just works” because you’re the one making it work.
The debugging process is slower because you can’t Google “Forgejo Actions docker push unauthorized” and find a Stack Overflow answer. You’re in uncharted territory, figuring things out as you go.
But once it works, it’s yours. You control it, you understand it, and you’re not at the mercy of someone else’s platform policies or pricing changes.
Would I recommend this path to everyone? No. GitHub Actions is easier, faster to set up, and Just Works™ for most use cases.
But if you’re already running self-hosted git infrastructure, if you care about owning your build pipeline, or if you just like understanding how things work under the hood, Forgejo Actions is worth the struggle.
Just remember to create your package namespaces manually first. You’ll thank me later.