Self-Hosted Gitea CI/CD: Auto-Deploy to a VPS with Actions

I recently needed to auto-deploy a private repo from my self-hosted Gitea instance to a VPS.

In my head, it sounded simple:

Push code to the Gitea repo, let something run on the VPS, pull the latest changes, install dependencies, and restart the app.

Simple, right?

Well, not quite.

I hit a few annoying issues along the way: Gitea Actions stuck on “waiting”, runner labels, SSH ports, access tokens, .env files, Docker socket permissions, and even pm2: command not found after everything else was already working.

So this post is the cleaned-up version of what finally worked.

The goal is simple:

Push to Gitea repo
→ Gitea Action runs
→ Gitea runner picks up the job
→ VPS runs a deploy script
→ latest code is pulled
→ dependencies are installed
→ PM2 or Docker restarts the app

This guide is for people who already have a VPS and a self-hosted Gitea instance, and want a practical CI/CD workflow without manually SSHing into the server every time.

Gitea Actions is Gitea’s built-in CI/CD system. It uses workflow files, much like GitHub Actions, and those jobs are executed by a runner. The official docs describe it as a built-in CI/CD solution for building, testing, and deploying code, with GitHub Actions-compatible workflow syntax: https://docs.gitea.com/usage/actions/overview


What We Are Building

We are building a deployment workflow like this:

Developer laptop
    ↓ git push
Self-hosted Gitea repo
    ↓ Gitea Actions
Gitea runner
    ↓ SSH
VPS deploy script
    ↓
PM2 or Docker Compose restarts the app

There are two deployment styles I’ll cover:

  1. PM2 deployment — good for Node.js apps already managed by PM2.
  2. Docker Compose deployment — good for containerised apps in any language.

The main idea is the same for both:

git fetch origin main
git reset --hard origin/main
# install/build/restart app

The Gitea Action itself does not need to know the full details of your app. It only needs to SSH into the VPS and run a deploy script.

That keeps the workflow simple.

What You Need Before Starting

You need:

A VPS
A self-hosted Gitea instance
A repo in Gitea
SSH access to the VPS
A Linux user that owns the app folder
Git installed on the VPS
A working app already deployable manually

For PM2 deployment, you also need PM2 installed and already managing your app.

PM2 is a Node.js process manager that keeps your app running in the background. The PM2 docs describe it as a daemon process manager to help manage and keep applications online: https://pm2.keymetrics.io/docs/usage/quick-start/

For Docker deployment, you need Docker and Docker Compose installed on the VPS.

Docker Compose’s up command can create, start, and recreate containers, while -d runs them in the background: https://docs.docker.com/reference/cli/docker/compose/up/

Gitea-to-VPS-workflow

The Important Concepts

Before writing any code, these are the moving parts:

Gitea Actions

This is the automation feature inside Gitea.

Runner

This is the worker that actually runs the job. Without a runner, your job will sit there waiting forever.

Workflow

This is a YAML file inside your repo, usually here:

.gitea/workflows/deploy.yml

Secrets

These are private values stored in Gitea, such as SSH keys, server usernames, hostnames, and tokens.

Gitea supports secrets at user, organisation, and repository level: https://docs.gitea.com/usage/actions/secrets

Deploy script

This is a shell script on your VPS that actually updates and restarts the app.

Deploy key or access token

This lets the VPS pull code from your private Gitea repo without asking for a username and password every time.

Repo-Level, Organisation-Level, or Instance-Level Runner?

Gitea runners can be scoped in different ways.

Repo-level runner

Available to one repo only.

Organisation-level runner

Available to all repos under one organisation or user.

Instance-level runner

Available to the whole Gitea instance.

For my case, an organisation-level runner made the most sense.

I did not want to set up a new runner every time I created a new app repo. But I also did not want every repo on the entire Gitea instance to automatically have access to the VPS runner.

So the balance was:

Organisation-level runner

That way, all repos under the organisation can use the runner, but unrelated repos cannot.

Recommended VPS Folder Layout

Use a clean folder layout.

For example:

/var/www/example-app
/home/deploy/deploy-example-app.sh

Or, if your VPS user is called deployuser:

/home/deployuser/apps/example-app
/home/deployuser/deploy-example-app.sh

The important thing is that the Linux user running the deploy script should own the app folder.

For example:

sudo chown -R deployuser:deployuser /var/www/example-app

Keep .env Safe

This is very important.

Your production .env file should live on the VPS, but it should not be committed to Git.

Add this to .gitignore:

.env
node_modules/
vendor/
dist/

Then check whether .env is tracked by Git:

git ls-files .env

If it prints nothing, good.

If it prints:

.env

then Git is tracking your .env, which is not what you want.

Remove it from Git tracking while keeping the actual file:

git rm --cached .env
echo ".env" >> .gitignore
git add .gitignore
git commit -m "Stop tracking env file"
git push origin main

Why does this matter?

Because the deploy script will use:

git reset --hard origin/main

That command overwrites tracked files to match the repo.

But it does not delete untracked files like .env.

So this is safe:

git reset --hard origin/main

But be careful with this:

git clean -fd

That can delete untracked files, including .env.

Unless you know exactly what you are doing, do not put git clean -fd in a production deploy script.

Prepare the VPS App Folder as a Git Repo

Your live app folder needs to be a Git working copy.

If you run this inside your app folder:

git remote -v

and you get:

fatal: not a git repository

then the folder is not connected to your Gitea repo yet.

A safe way to fix that is to back up the current folder, clone the repo fresh, and copy .env back.

Example:

cd /var/www

mv example-app example-app_backup

git clone https://git.example.com/org/example-app.git example-app

cp example-app_backup/.env example-app/.env

cd example-app

Then verify:

git remote -v
git fetch origin main
git ls-files .env

If git fetch origin main works without asking for a username and password, you are ready.

If it asks for username and password every time, you need to set up either SSH deploy keys or an HTTPS token.

Option A: Use an HTTPS Token for Git Pulls

If your Gitea SSH port is not easily reachable, HTTPS with a token is often simpler.

Create a Gitea access token in your Gitea account.

Then on the VPS, create:

nano ~/.netrc

Add:

machine git.example.com
login YOUR_GITEA_USERNAME
password YOUR_GITEA_TOKEN

Secure it:

chmod 600 ~/.netrc

Now clone normally:

git clone https://git.example.com/org/example-app.git example-app

And test:

cd example-app
git fetch origin main

If it works without prompting, your deploy script will also work.

This is the route I found easiest when SSH cloning became annoying because of custom SSH ports and firewall issues.

Option B: Use an SSH Deploy Key

You can also use an SSH deploy key.

On the VPS, as the deploy user:

ssh-keygen -t ed25519 -C "example-app deploy key"

Show the public key:

cat ~/.ssh/id_ed25519.pub

In Gitea, add it to:

Repo → Settings → Deploy Keys

Then use the SSH clone URL from Gitea, for example:

git clone ssh://git@git.example.com:2222/org/example-app.git example-app

Test it:

git fetch origin main

A useful lesson: if SSH times out, that is usually not a private repo problem. Private repo problems usually give authentication errors. Timeouts usually mean wrong port, firewall, Docker port mapping, or DNS issues.

Create the VPS Deploy Script

Now create the script that will actually deploy your app.

Example path:

/home/deployuser/deploy-example-app.sh

Create it:

nano /home/deployuser/deploy-example-app.sh

PM2 Deploy Script

Use this for a Node.js app managed by PM2:

#!/bin/bash
set -e

APP_DIR="/var/www/example-app"
PM2="/path/to/pm2"

cd "$APP_DIR"

echo "Deploy started at $(date)"

echo "Fetching latest code..."
git fetch origin main

echo "Resetting local files to match origin/main..."
git reset --hard origin/main

echo "Installing dependencies..."
npm install

echo "Restarting PM2 app..."
"$PM2" restart APP_NAME_OR_ID

echo "Saving PM2 process list..."
"$PM2" save

echo "Deploy completed at $(date)"

Make it executable:

chmod +x /home/deployuser/deploy-example-app.sh

Test it manually:

/home/deployuser/deploy-example-app.sh

Do not continue until this works manually.

Important PM2 Note: Use the Full PM2 Path

This was one of the issues I hit.

When I ran the deploy script manually, PM2 worked. But when the same script ran through Gitea Actions, it failed with:

pm2: command not found

The reason is that automated SSH sessions do not always load the same shell environment as your normal terminal.

Find the full PM2 path:

which pm2

You may get something like:

/usr/bin/pm2

or:

/opt/nvm/versions/node/v22.17.0/bin/pm2

Use that full path in the deploy script:

PM2="/opt/nvm/versions/node/v22.17.0/bin/pm2"

Then call PM2 like this:

"$PM2" restart APP_NAME_OR_ID
"$PM2" save

This removes the guesswork.

PM2 also has startup support so that saved processes can survive server restarts: https://pm2.keymetrics.io/docs/usage/startup/

Docker Compose Deploy Script

For a Docker Compose app, the deploy script can look like this:

#!/bin/bash
set -e

APP_DIR="/var/www/example-app"

cd "$APP_DIR"

echo "Deploy started at $(date)"

echo "Fetching latest code..."
git fetch origin main

echo "Resetting local files to match origin/main..."
git reset --hard origin/main

echo "Pulling latest images if needed..."
docker compose pull || true

echo "Rebuilding and restarting containers..."
docker compose up -d --build

echo "Deploy completed at $(date)"

Make it executable:

chmod +x /home/deployuser/deploy-example-app.sh

Test manually:

/home/deployuser/deploy-example-app.sh

For Docker Compose, I prefer:

docker compose up -d --build

rather than only:

docker compose restart

Why?

Because Docker’s docs note that docker compose restart does not apply changes to Compose configuration, such as environment variable changes. If the code, image, or Compose config changed, up -d --build is usually the safer deployment command: https://docs.docker.com/reference/cli/docker/compose/restart/

Install a Gitea Organisation-Level Runner

Now we need a runner.

If no runner is online, the Gitea job will sit in “waiting” forever.

In Gitea, go to your organisation or user area:

Organisation/User → Settings → Actions → Runners

Create a new organisation-level runner and copy the registration token.

Now on the VPS, create a dedicated runner user:

sudo adduser --system --group --home /var/lib/gitea-runner act_runner

Create folders:

sudo mkdir -p /var/lib/gitea-runner /etc/gitea-runner
sudo chown -R act_runner:act_runner /var/lib/gitea-runner /etc/gitea-runner

Download Gitea Runner.

For Linux AMD64:

cd /tmp

wget -O gitea-runner https://dl.gitea.com/gitea-runner/1.0.0/gitea-runner-1.0.0-linux-amd64

chmod +x gitea-runner

sudo mv gitea-runner /usr/local/bin/gitea-runner

/usr/local/bin/gitea-runner --version

If the download URL changes in future, check the official Gitea runner downloads page or documentation.

Generate config:

sudo -u act_runner /usr/local/bin/gitea-runner generate-config | sudo tee /etc/gitea-runner/config.yaml >/dev/null

sudo chown act_runner:act_runner /etc/gitea-runner/config.yaml

Register the runner:

sudo -u act_runner bash

Inside that shell:

cd /var/lib/gitea-runner

/usr/local/bin/gitea-runner register \
  --config /etc/gitea-runner/config.yaml \
  --instance https://git.example.com

It will ask for:

Runner token
Runner name
Runner labels

For name, use something descriptive:

example-org-vps-runner

For labels, you can use:

ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04

Or, if you want host-mode execution:

ubuntu-latest:host,ubuntu-22.04:host,linux:host

Gitea’s runner docs explain that runners can run jobs in Docker mode or host mode. Docker mode gives isolation, while host mode runs directly on the host and gives no encapsulation: https://docs.gitea.com/usage/actions/act-runner

Exit the runner user shell:

exit

Create the Gitea Runner Systemd Service

Create the service:

sudo nano /etc/systemd/system/gitea-runner.service

Paste:

[Unit]
Description=Gitea Actions Runner
After=network.target

[Service]
User=act_runner
Group=act_runner
WorkingDirectory=/var/lib/gitea-runner
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-runner/config.yaml
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Start it:

sudo systemctl daemon-reload
sudo systemctl enable gitea-runner
sudo systemctl restart gitea-runner
sudo systemctl status gitea-runner --no-pager

Check logs:

sudo journalctl -u gitea-runner -n 80 --no-pager

Back in Gitea, the runner should show as online.

If the Runner Fails with Docker Permission Denied

This is another issue I hit.

The service failed with an error like:

cannot ping the docker daemon
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock

If your runner is using Docker-based labels, the act_runner user needs permission to access Docker.

Fix:

sudo usermod -aG docker act_runner
sudo systemctl restart gitea-runner
sudo systemctl status gitea-runner --no-pager

Then check:

sudo journalctl -u gitea-runner -n 80 --no-pager

Security warning: membership in the Docker group is powerful. It can effectively give broad control over the host. Only let trusted repos and trusted users use this runner.

Gitea’s Actions FAQ also warns that malicious scripts can control a runner if untrusted workflows are allowed to run on it: https://docs.gitea.com/usage/actions/faq

Add SSH Secrets to the Gitea Repo

The workflow needs to SSH into the VPS and run the deploy script.

Create a new SSH key for this purpose:

ssh-keygen -t ed25519 -C "gitea-action-to-vps"

Leave the passphrase empty if you want non-interactive deployment.

You will get:

gitea_action_to_vps
gitea_action_to_vps.pub

Add the public key to the VPS deploy user:

sudo -iu deployuser

mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys

Paste the public key.

Fix permissions:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Now test from wherever the private key is:

ssh -i gitea_action_to_vps deployuser@YOUR_VPS_HOST

If it logs in without a password, good.

Now add these secrets in Gitea:

Repo or Organisation → Settings → Actions → Secrets

Add:

VPS_HOST = your.vps.hostname.or.ip
VPS_USER = deployuser
VPS_SSH_KEY = contents of the private key

For VPS_SSH_KEY, paste the whole private key:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Do not paste the .pub key as the secret. The .pub key goes into authorized_keys on the VPS. The private key goes into Gitea secrets.

Create the Gitea Actions Workflow

In your repo, create:

.gitea/workflows/deploy.yml

Add:

name: Deploy App

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Prepare SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/vps_key
          chmod 600 ~/.ssh/vps_key
          ssh-keyscan -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts

      - name: Run deploy script on VPS
        run: |
          ssh -i ~/.ssh/vps_key "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
            "/home/deployuser/deploy-example-app.sh"

Commit and push:

git add .gitea/workflows/deploy.yml
git commit -m "Add Gitea deployment workflow"
git push origin main

Now go to:

Repo → Actions

You should see the workflow run.

What Happens When the Job Runs?

The job does this:

1. Creates a temporary SSH key file inside the runner job
2. Adds the VPS host to known_hosts
3. SSHs into the VPS
4. Runs the deploy script
5. Deploy script pulls latest code
6. Deploy script installs/builds/restarts the app

If you are using Docker-based runner labels, Gitea Runner will create a temporary Docker container, temporary volumes, and a temporary network for the job.

After the job finishes, it may remove them.

That is normal.

You may see logs like:

Cleaning up container for job deploy
Removed container: ...
docker volume rm ...
Cleaning up network for job deploy
Job succeeded

This is expected behaviour. It is cleaning up the temporary job environment, not your actual app.

Testing the Whole Setup

First test the deploy script manually:

/home/deployuser/deploy-example-app.sh

Then test SSH:

ssh -i gitea_action_to_vps deployuser@YOUR_VPS_HOST "/home/deployuser/deploy-example-app.sh"

Then test Gitea Actions by pushing a small commit:

git commit --allow-empty -m "Test deployment"
git push origin main

Check:

Gitea → Repo → Actions

If it succeeds, check your app:

For PM2:

pm2 list
pm2 logs APP_NAME_OR_ID --lines 50

For Docker:

docker compose ps
docker compose logs --tail=50

Troubleshooting: Problems I Hit

Job is stuck on “waiting”

This usually means one of these:

No runner is online
Runner is registered at the wrong scope
Workflow label does not match runner label

Check:

sudo systemctl status gitea-runner --no-pager
sudo journalctl -u gitea-runner -n 80 --no-pager

Also check in Gitea:

Settings → Actions → Runners

If your workflow says:

runs-on: ubuntu-latest

then your runner must have a matching label for ubuntu-latest.

Runner says it registered but service fails

Check logs:

sudo journalctl -u gitea-runner -n 80 --no-pager

If you see Docker permission errors:

cannot ping the docker daemon
permission denied while trying to connect to /var/run/docker.sock

fix it:

sudo usermod -aG docker act_runner
sudo systemctl restart gitea-runner

Git keeps asking for username and password

Use one of these:

SSH deploy key
HTTPS token with ~/.netrc
Git credential helper

For .netrc:

machine git.example.com
login YOUR_GITEA_USERNAME
password YOUR_GITEA_TOKEN

Then:

chmod 600 ~/.netrc

Test:

git fetch origin main

SSH clone times out

Example:

ssh: connect to host git.example.com port 2222: Connection timed out

This is not usually a private repo issue.

It usually means:

Wrong SSH port
Firewall blocking the port
Docker port not published
DNS pointing somewhere unexpected

Private repo issues usually show authentication errors, not timeouts.

.env is at risk

Check:

git ls-files .env

If it returns .env, remove it from Git tracking:

git rm --cached .env
echo ".env" >> .gitignore
git add .gitignore
git commit -m "Stop tracking env file"
git push origin main

Avoid:

git clean -fd

unless you understand exactly what it will delete.

pm2: command not found

Find PM2:

which pm2

Use the full path in your deploy script:

PM2="/opt/nvm/versions/node/v22.17.0/bin/pm2"

"$PM2" restart APP_NAME_OR_ID
"$PM2" save

This is especially important when the deploy script runs through SSH from an automated job.

npm install works, but app does not restart

Test PM2 manually:

pm2 restart APP_NAME_OR_ID

If that works manually but not inside the deploy script, use the full PM2 path.

If the PM2 app ID changes often, use the app name instead of the numeric ID.

For example:

pm2 restart example-app

is usually more stable than:

pm2 restart 4

PM2 or Docker Compose?

Use PM2 if:

Your app is a simple Node.js app
You already use PM2
You want a quick and direct deployment
You do not need full containerisation

Use Docker Compose if:

Your app has multiple services
You want predictable containers
You deploy apps in different languages
You need database/cache/app orchestration
You want the same runtime structure everywhere

PM2 is simple and effective for Node apps.

Docker Compose is better when your app is more than one process or you want a more portable deployment setup.

Security Notes

A few things are worth taking seriously:

Do not commit .env
Do not paste private keys into code
Use Gitea secrets
Use a dedicated deploy user
Do not let untrusted repos use your runner
Prefer org-level runner over instance-level if only one org needs deployment
Avoid git clean -fd in production folders
Keep runner logs clean of secrets

If you add the runner user to the Docker group, understand that this is powerful. A workflow running on that runner can potentially do a lot on the host.

So only allow trusted repos and trusted people to use that runner.

Final Checklist

Before you call it done, confirm:

Your app folder is a Git repo
The VPS can git fetch without asking for username/password
.env is not tracked by Git
The deploy script works manually
PM2 or Docker restart works manually
The runner is online in Gitea
The workflow runs-on label matches the runner label
Gitea secrets are configured
The workflow can SSH into the VPS
A push to main triggers deployment
The job ends with “Job succeeded”

If all of those are true, your CI/CD setup is working.

Final Thoughts

This setup sounds complicated at first, but the finished version is actually simple:

Gitea Action
→ SSH into VPS
→ run deploy script

That is it.

The runner does not need to know all the details of your app. Your workflow does not need to become a giant YAML monster. Keep the real deployment logic in a script on the VPS, test that script manually, then let Gitea trigger it automatically.

Once I got the runner online, fixed Docker permissions, used proper secrets, and used the full PM2 path, everything worked smoothly.

Now, pushing to the Gitea repo automatically updates the VPS app.

And honestly, that is exactly how it should be.

← Back to Blog

Comments (0)

No comments yet. Be the first to share your thoughts!

Leave a Comment

Your email address will not be published. Required fields are marked *