The goal
After the manual deployment worked, I wanted every push to main to:
- Update the deployment checkout.
- Build Hugo with minification.
- Stop immediately if the build failed.
- Publish the static output.
- Verify the live HTTPS site.
Gitea Actions provides the orchestration, but jobs require a separate runner. The Gitea UI only displays a registration token; it does not create a machine or start a runner.
Registering a repository runner
I installed Gitea Runner 1.0.0 on the deployment host and registered it specifically for the private blog repository. The generated .runner file contains its identity and must not be committed, copied into an image, or included in a backup that other users can read.
The first daemon startup reported default labels:
[ubuntu-latest ubuntu-24.04 ubuntu-22.04]
Those labels run jobs in containers. This deployment needs access to an existing checkout and deployment directory, so I configured a distinctive host label:
runner:
labels:
- "blog-deploy:host"
After restarting, the runner declared the blog-deploy label.
A host runner executes repository workflow commands directly on the server. It has the same access as the runner's operating-system user. If that user belongs to the Docker group, workflow code is effectively root-capable because it can ask the Docker daemon to mount or modify host files.
For that reason, the minimum controls I recommend for this design are:
- A repository-scoped runner rather than an instance-wide runner.
- A private repository with registration disabled.
- A dedicated operating-system account.
- A custom label that other jobs will not select accidentally.
- Branch protection and review for workflow changes.
- No
pull_requesttrigger for untrusted contributions. - A registration credential readable only by the runner account.
Container jobs provide better isolation for ordinary CI. I chose a host job here only because publishing requires controlled host access.
Keeping the runner alive
My initial runner used an existing host account with Docker access. A safer reusable pattern is a systemd service under a dedicated deployment user. The names below are generic examples rather than my production account and paths:
[Unit]
Description=Gitea Actions Runner
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=blogdeploy
Group=blogdeploy
WorkingDirectory=/opt/gitea-runner
ExecStart=/opt/gitea-runner/gitea-runner \
-c /opt/gitea-runner/config.yaml daemon
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
The runner directory and .runner credential are owned by that account and are not writable by the web server.
Useful service commands are:
sudo systemctl enable --now gitea-runner
sudo systemctl status gitea-runner --no-pager
sudo journalctl -u gitea-runner -f
The workflow
The repository contains .gitea/workflows/deploy.yml:
name: Deploy Hugo blog
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: blog-deploy
env:
SOURCE_DIR: /opt/example-blog/source
COMPOSE_DIR: /opt/example-stack
DEPLOY_DIR: /srv/example-blog/current
SITE_URL: https://example.com/
steps:
- name: Update deployment checkout
run: |
set -euo pipefail
cd "$SOURCE_DIR"
git fetch origin main
git checkout main
git merge --ff-only origin/main
git submodule update --init --recursive
- name: Build Hugo site
run: |
set -euo pipefail
cd "$COMPOSE_DIR"
docker compose run --rm --no-deps hugo --minify
test -s "$SOURCE_DIR/public/index.html"
- name: Publish site
run: |
set -euo pipefail
test "$DEPLOY_DIR" = "/srv/example-blog/current"
test -d "$DEPLOY_DIR"
rsync -a --delete \
"$SOURCE_DIR/public/" \
"$DEPLOY_DIR/"
- name: Verify deployment
run: |
set -euo pipefail
test -s "$DEPLOY_DIR/index.html"
curl --fail --silent --show-error \
--retry 3 \
"$SITE_URL" \
> /dev/null
set -euo pipefail ensures an error stops each script. The build verifies that index.html exists before publication, and the last step tests the public endpoint. The exact-path assertion and directory check are deliberate safeguards: rsync --delete must never receive an empty, misspelled, or unexpectedly expanded destination.
This article focuses on runner orchestration. The separation among Hugo source, generated output, and Nginx's read-only mount is covered in Building and serving a production Hugo site with Docker and Nginx.
The first deployment failure
The first automated publish failed with:
mkstemp ... Permission denied
failed to set times ... Operation not permitted
The initial manual deployment had used sudo rsync, so the destination and its files belonged to root. The systemd runner correctly ran as a non-root account and could not replace them.
For a new deployment directory, I prefer creating it with the intended owner:
sudo install -d \
-o blogdeploy \
-g blogdeploy \
-m 0755 \
/srv/example-blog/current
For an existing directory, I first resolve and inspect the exact target before changing ownership:
DEPLOY_DIR=/srv/example-blog/current
test "$(readlink -f -- "$DEPLOY_DIR")" = "/srv/example-blog/current"
sudo find "$DEPLOY_DIR" -maxdepth 1 -printf '%M %u:%g %p\n'
Only after verifying that dedicated path would I correct its ownership:
test "$(readlink -f -- "$DEPLOY_DIR")" = "/srv/example-blog/current" &&
sudo chown -hR -- blogdeploy:blogdeploy "$DEPLOY_DIR"
I verified access using the actual runner identity:
sudo -u blogdeploy touch /srv/example-blog/current/.runner-write-test
sudo -u blogdeploy rm /srv/example-blog/current/.runner-write-test
Future deployments should not use sudo rsync, because that would recreate root-owned output.
What this pipeline guarantees
The workflow does not publish a partially failed Hugo build. A successful run means:
- The deployment checkout reached the pushed
maincommit. - Theme submodules were initialized.
- Hugo completed with minification.
- A non-empty homepage was generated.
- Generated files reached the Nginx document root.
- The public HTTPS endpoint returned success.
The HTTP check proves availability, not that the newest commit is being served. A stronger pipeline can publish a small revision file and verify its expected commit SHA.
The next improvements I would consider are deploy locking, release directories with atomic symlink switching, automatic rollback, and a dedicated root-owned deployment script exposed to the runner through one narrowly scoped sudoers rule. That design can remove direct Docker-group access from the runner.