karsttech.com/content/personal-blog/forgejo-actions-portainer-automation/index.md

216 lines
9.8 KiB
Markdown
Raw Normal View History

+++
title = 'Automatic Deployment with Forgejo Actions and Portainer CE'
description = 'Wiring up self-hosted Git forgejo, a CI runner, and a container manager to deploy on every push'
date = 2026-05-06T10:00:00-05:00
draft = false
categories = ['projects']
tags = ['Forgejo', 'Portainer', 'Docker', 'CI/CD', 'Self-Hosted', 'DevOps', 'Automation']
+++
{{< lead >}}
Push to your repo, watch your container redeploy. No cloud required.
{{< /lead >}}
Self hosting repositories, containers, and automating their deployment is easier than ever. There are enough moving parts involved that I decided to write up a how-to guide to make it easier for others to follow.
## Moving Parts
This guide covers using Portainer Community Edition as a docker container manager, including stacks / docker compose scripting, forgejo as a Git repository and forgejo actions for automation.
- **Forgejo**: self-hosted Git forge that also runs the Actions workflow engine
- **Forgejo Runner**: a separate process that actually executes the workflow jobs including building docker images
- **Portainer CE**: Docker management UI that exposes a webhook to trigger redeployment
The end result is that when you `git push` an update to the repository, it triggers portainer to rebuild and deploy your docker container automatically. As of recently, this website is hosted using exactly this process.
Portainer's "Re-pull image" and "Force redeployment" features would make parts of this easier, but they are Business-tier only (can't fault a business for wanting to make money I suppose). The webhook trigger itself is free in CE, and that is enough.
## Forgejo
The Forgejo stack is straightforward. The one non-obvious requirement is that Actions must be explicitly enabled via an environment variable since it is off by default.
```yaml
version: "3"
services:
server:
image: codeberg.org/forgejo/forgejo:10
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__database__DB_TYPE=sqlite3
- FORGEJO__actions__ENABLED=true
restart: unless-stopped
volumes:
- data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "222:22"
volumes:
data:
```
After deploying, Actions also needs to be enabled per-repository under **Settings → General → Features → Actions**.
## Forgejo Runner
To build Docker images, the runner needs access to a Docker daemon. Rather than mounting the host Docker socket, which would give workflow jobs control over every container on the host, we can use Docker-in-Docker (DIND): a separate container running its own isolated Docker daemon that the runner talks to over TCP.
The DIND daemon is bound to `127.0.0.1` rather than `0.0.0.0` so it is only reachable from the host itself and not exposed to the rest of the network. All three containers use `network_mode: host` so they share the host's network stack and can reach each other via `localhost`, and so the runner inherits the host's DNS configuration for builds, this was mostly done because I use pihole and several other custom DNS configurations in my network.
```yaml
version: "3"
services:
docker-in-docker:
image: docker:dind
container_name: forgejo-dind
privileged: true
restart: unless-stopped
command: ["dockerd", "-H", "tcp://127.0.0.1:2376", "--tls=false"]
network_mode: host
runner-register:
image: data.forgejo.org/forgejo/runner:9
container_name: forgejo-runner-register
restart: "no"
depends_on:
- docker-in-docker
environment:
- DOCKER_HOST=tcp://localhost:2376
network_mode: host
command: >
/bin/sh -c "
if [ ! -f /data/.runner ]; then
forgejo-runner register
--no-interactive
--instance https://YOUR_FORGEJO_DOMAIN
--token YOUR_REGISTRATION_TOKEN
--name forgejo-runner
--labels self-hosted;
fi"
volumes:
- runner-data:/data
runner:
image: data.forgejo.org/forgejo/runner:9
container_name: forgejo-runner
restart: unless-stopped
command: /bin/sh -c "sleep 5 && forgejo-runner daemon"
depends_on:
docker-in-docker:
condition: service_started
runner-register:
condition: service_completed_successfully
environment:
- DOCKER_HOST=tcp://localhost:2376
network_mode: host
volumes:
- runner-data:/data
volumes:
runner-data:
```
A few things that caused friction along the way:
**The instance URL.** Use your public HTTPS Forgejo URL rather than a local IP. Docker's login action assumes HTTPS and will fail if given an HTTP registry, and stripping the protocol from a URL in a workflow is messier than just using HTTPS from the start.
**The sleep.** The runner daemon starts faster than the DIND daemon initializes. A five second sleep in the runner command is an inelegant but reliable fix.
## Registry credentials
Forgejo includes a container registry out of the box, we only need to pass it credentials from the runner.
Forgejo supports user-level secrets that are available to all your repositories automatically, and this suits my needs to give the runner access to every repository under my user account. It gets read level permissions on all repos (even private), and package write permissions for pushing images. If you wanted to be paranoid you could have separate credentials on runners for private repos and public repos.
### Create a personal access token
Go to **User Settings → Applications → Generate Token**. Give it a name like `registry-push` and enable the following permissions:
- **package**: read/write (for pushing and pulling images)
- **repository**: read (for checking out code during the build)
### Add a user-level secret
Go to **User Settings → Secrets** and create one secret:
- `REGISTRY_PASSWORD` is the personal access token you just generated
## Project docker-compose.yml
Instead of building from a Dockerfile locally, Portainer now pulls a pre-built image from the Forgejo registry. If you had one, replace the `build` section with an `image` reference:
```yaml
name: trmnl-imagegen
services:
trmnl:
image: YOUR_FORGEJO_DOMAIN/YOUR_USERNAME/YOUR_IMAGE:latest
restart: unless-stopped
```
## Portainer registry authentication
Since the repository is private, Portainer needs credentials to pull the image. Go to **Registries → Add Registry → Custom Registry** and fill in:
- **Registry URL**: your Forgejo domain
- **Username**: your Forgejo username
- **Password**: the same personal access token created above
Once added, Portainer will use these credentials automatically when pulling images from that registry.
On the stack's **GitOps updates** section, enable the webhook mechanism and copy the generated URL. Add it as a secret at the repository level in Forgejo under **Settings → Secrets and Variables → Actions**, named `PORTAINER_WEBHOOK_URL`. This is the one secret that must be set per-repository since it is specific to each Portainer stack.
## Workflow
With everything in place, the workflow builds the image, pushes it to the registry, and triggers Portainer to redeploy, all from a single file at `.forgejo/workflows/deploy.yml`:
```yaml
on:
push:
2026-05-06 23:20:58 -04:00
jobs:
build-and-deploy:
2026-05-06 23:20:58 -04:00
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: self-hosted
env:
2026-05-06 23:20:58 -04:00
DOCKER_HOST: tcp://localhost:2376
container:
image: catthehacker/ubuntu:act-latest
options: --network host
steps:
- uses: actions/checkout@v4
- name: Log in to Forgejo registry
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_SERVER_URL }}
username: ${{ github.repository_owner }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set registry host
run: echo "REGISTRY_HOST=${GITHUB_SERVER_URL#https://}" >> $GITHUB_ENV
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
- name: Trigger Portainer redeploy
run: curl -k -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
```
The registry URL, username, and image name are all derived from built-in workflow variables, making this file completely portable. If you drop it into any repository it should work without modification. The only secret that needs to be set per-repository is `PORTAINER_WEBHOOK_URL`, since that is specific to each Portainer stack. `REGISTRY_PASSWORD` is set once at the user level and inherited everywhere.
The job runs inside `catthehacker/ubuntu:act-latest`, a purpose-built image that replicates the GitHub-hosted runner environment with Node.js, Docker CLI, git, and common build tools pre-installed. It is the standard image used by the `act` project for running GitHub Actions locally, and is the most practical choice for self-hosted Forgejo runners. The `DOCKER_HOST` variable points the Docker CLI at the DIND daemon running on the host, and `--network host` ensures the job container can reach it.
The `-k` flag on the `curl` command disables SSL verification, which is necessary when Portainer is running with a self-signed certificate on a local network, if you have a valid SSL certificate on your Portainer instance this can be removed.
## Why bother
As the number of projects I write and host locally accumulates, as well as the number of updates to those projects, the time required to manually deploy them increases and starts to become more annoying. Manually clicking "Pull and redeploy" in Portainer is not a huge burden, and setting this up might not ever repay itself in the time cost, but I learned quite a bit from doing it and it feels good to watch the toolchain run automatically when I push changes.
Hope this helps some of you!