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 same Droplet and registered it specifically for the blog repository. The generated .runner file contains its identity and must not be committed or shared.
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 existing host paths and the host’s Docker Compose project, 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. That is intentionally powerful and should only be attached to a trusted repository whose workflow and default branch are protected.
Keeping the runner alive
I installed the runner as a systemd service under my unprivileged deployment user:
[Unit]
Description=Gitea Actions Runner
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=minhdb
Group=minhdb
WorkingDirectory=/home/minhdb/gitea-runner
Environment=HOME=/home/minhdb
ExecStart=/home/minhdb/gitea-runner/gitea-runner \
-c /home/minhdb/gitea-runner/config.yaml daemon
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
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
steps:
- name: Update deployment checkout
run: |
set -euo pipefail
cd /home/minhdb/docker/2023-blog
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 /home/minhdb/docker
docker compose run --rm --no-deps hugo --minify
test -s /home/minhdb/docker/2023-blog/public/index.html
- name: Publish site
run: |
set -euo pipefail
rsync -a --delete \
/home/minhdb/docker/2023-blog/public/ \
/srv/minhdb-blog/current/
- name: Verify deployment
run: |
set -euo pipefail
test -s /srv/minhdb-blog/current/index.html
curl --fail --silent --show-error \
--retry 3 \
https://minhdb.io/ \
> /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 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 minhdb and could not replace them.
The one-time correction was:
sudo chown -R minhdb:minhdb /srv/minhdb-blog
I verified access using the actual runner identity:
sudo -u minhdb touch /srv/minhdb-blog/current/.runner-write-test
sudo -u minhdb rm /srv/minhdb-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 next improvements I would consider are deploy locking, release directories with atomic symlink switching, automatic rollback, and branch protection requiring a successful build before merge.