9.1 KiB
Deploy Workflow — ${REPO_NAME}
Overview
The deploy workflow (.gitea/workflows/deploy.yml) automates deployment to a
VPS after a push to the default branch. It supports two modes and three
deployment strategies.
Disabled by default. Set ENABLE_DEPLOY=true in .ci/config.env to enable.
Deployment Modes
Mode A: Local Runner (Recommended)
The deploy job runs directly on the VPS via a self-hosted act_runner.
No SSH keys or network credentials needed — the runner already has local access.
┌──────────┐ push ┌──────────┐ runs-on: deploy-ovh ┌───────────┐
│ Developer │ ────────────► │ Gitea │ ────────────────────────► │ VPS │
│ │ │ Server │ │ act_runner│
└──────────┘ └──────────┘ │ (local) │
└───────────┘
Pros:
- No SSH secrets to manage
- No network exposure (runner is already on the VPS)
- Simpler setup, fewer moving parts
- Runner can access local Docker socket, systemd, files directly
Cons:
- Requires installing
act_runneron the VPS - One runner per VPS (or one runner with multiple labels)
Mode B: SSH (Fallback)
The deploy job runs on any runner (e.g., shared ubuntu-latest) and SSHs into the VPS to execute deploy commands remotely.
┌──────────┐ push ┌──────────┐ runs job ┌────────┐ SSH ┌─────┐
│ Developer │ ────────────► │ Gitea │ ──────────────► │ Runner │ ────────► │ VPS │
└──────────┘ └──────────┘ │(shared)│ └─────┘
└────────┘
Pros:
- Works without installing anything on the VPS
- Can deploy to VPSes you don't fully control
Cons:
- Requires SSH secrets (private key, host, user)
- VPS SSH port must be reachable from the runner
- More moving parts, more to go wrong
Deploy Strategies
Regardless of mode, the workflow supports three strategies for what actually happens on the VPS:
compose (default)
cd /opt/myapp
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
Best for: Docker Compose-based projects. Pulls latest images and recreates containers with zero-downtime (depends on your compose config).
systemd
sudo systemctl restart my-service
Best for: Applications managed as systemd services (e.g., a Go binary, Python app with gunicorn, etc.).
script
./scripts/deploy.sh /opt/myapp
Best for: Custom deploy logic that doesn't fit compose or systemd.
The script receives DEPLOY_WORKDIR as $1.
Setup Guide
Local Runner Mode
Step 1: Install act_runner on the VPS
# Download the latest act_runner binary
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
chmod +x act_runner-linux-amd64
sudo mv act_runner-linux-amd64 /usr/local/bin/act_runner
Step 2: Register the runner with your label
# Register with Gitea (get the token from: Site Admin → Runners → Create)
act_runner register \
--instance https://git.hiddenden.cafe \
--token YOUR_RUNNER_TOKEN \
--labels deploy-ovh
# Or for repo-level: Repository Settings → Actions → Runners → Create
Step 3: Configure runner labels
The runner config file (usually ~/.config/act_runner/config.yaml or next to
the binary) contains the labels:
runner:
labels:
- "deploy-ovh:host"
The :host suffix means the job runs directly on the host (not in a container).
This is important for accessing Docker, systemd, and local files.
After changing labels, restart the runner:
sudo systemctl restart act_runner
Step 4: Update workflow runs-on
In .gitea/workflows/deploy.yml, the deploy-local-runner job has:
runs-on: deploy-ovh
If you changed DEPLOY_RUNNER_LABEL in config.env, you must also update
this runs-on value in the workflow file to match. Gitea Actions does not
support dynamic runs-on from environment variables.
Step 5: Enable deploy
In .ci/config.env:
ENABLE_DEPLOY=true
DEPLOY_MODE=local-runner
DEPLOY_RUNNER_LABEL=deploy-ovh
DEPLOY_WORKDIR=/opt/myapp
DEPLOY_STRATEGY=compose
Step 6: Verify VPS prerequisites
For compose strategy:
# Docker and compose plugin must be installed
docker --version
docker compose version
# The deploy workdir must exist with a docker-compose.yml
ls /opt/myapp/docker-compose.yml
For systemd strategy:
# The service must exist
systemctl cat my-service
For script strategy:
# The script must be in the repo and executable
SSH Mode
Step 1: Create an SSH key
ssh-keygen -t ed25519 -C "deploy@gitea" -f deploy_key -N ""
Step 2: Add the public key to the VPS
ssh-copy-id -i deploy_key.pub user@your-vps
Step 3: Add secrets to Gitea
Go to Repository Settings → Actions → Secrets and add:
| Secret | Value |
|---|---|
DEPLOY_SSH_KEY |
Contents of deploy_key (private key) |
DEPLOY_HOST |
VPS IP or hostname |
DEPLOY_USER |
SSH username |
DEPLOY_KNOWN_HOSTS |
Output of ssh-keyscan your-vps (recommended) |
Step 4: Enable deploy
In .ci/config.env:
ENABLE_DEPLOY=true
DEPLOY_MODE=ssh
DEPLOY_WORKDIR=/opt/myapp
DEPLOY_STRATEGY=compose
Configuration Reference
| Variable | Default | Description |
|---|---|---|
ENABLE_DEPLOY |
false |
Master switch. Deploy never runs unless true. |
DEPLOY_MODE |
local-runner |
local-runner or ssh |
DEPLOY_RUNNER_LABEL |
deploy-ovh |
Runner label for local-runner mode |
DEPLOY_WORKDIR |
/opt/${REPO_NAME} |
Working directory on the VPS |
DEPLOY_STRATEGY |
compose |
compose, systemd, or script |
DEPLOY_COMPOSE_FILE |
docker-compose.yml |
Compose file path (relative to workdir) |
DEPLOY_SYSTEMD_SERVICE |
(empty) | Systemd service name (required for systemd strategy) |
DEPLOY_SCRIPT |
scripts/deploy.sh |
Deploy script path (relative to repo root) |
DEPLOY_ON_TAG |
false |
Also deploy on v* tag pushes |
Safety
Branch Protection
The workflow only deploys from the DEFAULT_BRANCH (main). This is enforced
in two places:
- Trigger filter:
on.push.branches: [main] - Branch guard step: Runtime check comparing the actual ref to
DEFAULT_BRANCH
Never on Pull Requests
The pull_request event is intentionally absent from the trigger list.
Deploy cannot run on PRs.
Protected Branches
For maximum safety, enable branch protection on main in Gitea:
- Go to Repository Settings → Branches → Branch Protection
- Enable for
main - Require: status checks to pass, review approval, no force push
- This ensures only reviewed, passing code reaches main → gets deployed
Secrets
- Local-runner mode: No secrets needed. The runner has local access.
- SSH mode: Private key is written to a temp file, used once, then deleted. It is never echoed to logs.
Runner Label Examples
| Label | Description |
|---|---|
deploy-ovh |
OVH VPS runner |
vps-prod |
Production VPS |
deploy-hetzner |
Hetzner VPS runner |
staging-runner |
Staging environment |
Troubleshooting
Local Runner Mode
- Runner registered? Check Gitea Admin → Runners or Repo Settings → Runners
- Label matches?
runs-onin workflow must match the runner's label exactly - Runner online? Check runner status in Gitea; restart if offline
- Label suffix correct? Use
:hostfor direct host execution (not container) - Docker accessible? Runner user must be in the
dockergroup - Workdir exists?
DEPLOY_WORKDIRmust exist and be readable - Compose file present? Check
docker-compose.ymlexists in the workdir - Permissions? For systemd: runner user needs sudo for systemctl
SSH Mode
- Secrets set? Check all four secrets are in repo settings
- Key format? Private key must include
-----BEGINheader - SSH port open? VPS firewall must allow port 22 (or custom port) from runner
- User permissions? SSH user needs access to Docker/systemd/workdir
- Known hosts? Set
DEPLOY_KNOWN_HOSTSfor production (avoids MITM risk) - Key not passphrase-protected? CI keys must have no passphrase
General
- ENABLE_DEPLOY=true? Check
.ci/config.env - Pushing to main? Deploy only triggers on
DEFAULT_BRANCH - Workflow file correct? YAML syntax errors prevent the workflow from running
- Check Actions tab in Gitea for workflow run logs