Gitea Actions on a VPS: Self-Hosted Git CI/CD Without GitHub or GitLab
Gitea is a lightweight, self-hosted Git service with a GitHub-like interface. Gitea Actions (added in Gitea 1.19) runs CI/CD workflows using the same YAML syntax as GitHub Actions — meaning your existing .github/workflows/ files work in Gitea with minimal changes. Self-hosting Gitea + Actions on a VPS gives your team a complete Git and CI/CD platform with no per-seat pricing, no usage limits, and your code never leaving your infrastructure.
Why Self-Host Instead of GitHub/GitLab
- Data sovereignty: Code, secrets, and artifacts stay on your infrastructure
- No usage limits: Unlimited repositories, CI minutes, and storage
- Cost: VPS cost replaces GitHub Teams ($4/user/month) or GitLab Premium ($19/user/month)
- Performance: Local runner — no queue, full VPS CPU for builds
- Gitea Actions compatibility: GitHub Actions YAML syntax — migrate workflows with minimal changes
Step 1: Docker Compose Setup
<code">mkdir -p /opt/gitea && cd /opt/gitea nano docker-compose.yml
<code">version: '3.8'
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: always
ports:
- "127.0.0.1:3000:3000" # Web UI
- "2222:22" # SSH for git push (non-standard port)
environment:
USER_UID: 1000
USER_GID: 1000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: gitea-db:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: ${POSTGRES_PASSWORD}
GITEA__server__DOMAIN: git.yourdomain.com
GITEA__server__ROOT_URL: https://git.yourdomain.com/
GITEA__server__SSH_DOMAIN: git.yourdomain.com
GITEA__server__SSH_PORT: 2222
GITEA__actions__ENABLED: "true"
GITEA__mailer__ENABLED: "true"
GITEA__mailer__SMTP_ADDR: smtp.mailgun.org
GITEA__mailer__SMTP_PORT: 587
GITEA__mailer__USER: postmaster@mg.yourdomain.com
GITEA__mailer__PASSWD: ${SMTP_PASSWORD}
GITEA__mailer__FROM: gitea@yourdomain.com
volumes:
- gitea_data:/data
depends_on:
gitea-db:
condition: service_healthy
gitea-db:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- gitea_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 10s
timeout: 5s
retries: 5
volumes:
gitea_data:
gitea_db:
<code">cat > .env << 'EOF' POSTGRES_PASSWORD=StrongGiteaDbPassword! SMTP_PASSWORD=your_mailgun_smtp_password EOF chmod 600 .env docker compose up -d
Step 2: Nginx Reverse Proxy
<code">sudo nano /etc/nginx/sites-available/gitea
<code">server {
listen 80;
server_name git.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name git.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/git.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.yourdomain.com/privkey.pem;
client_max_body_size 200M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
<code">sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/ sudo certbot --nginx -d git.yourdomain.com sudo systemctl reload nginx
Step 3: Initial Setup
- Visit
https://git.yourdomain.com - Complete the install wizard — database settings are pre-configured via environment variables
- Create the admin account
- Site Administration → Settings → Actions → Enable Actions globally
Step 4: Install act_runner (Actions Executor)
<code"># Download act_runner (runs Gitea Actions jobs)
wget -O /usr/local/bin/act_runner \
https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
chmod +x /usr/local/bin/act_runner
# Create runner directory
mkdir -p /opt/act_runner && cd /opt/act_runner
# Generate config
act_runner generate-config > config.yaml
# Register runner with Gitea
# First: get registration token from Gitea Admin → Actions → Runners
act_runner register \
--instance https://git.yourdomain.com \
--token YOUR_REGISTRATION_TOKEN \
--name "vps-runner" \
--labels "ubuntu-latest:docker://node:20,ubuntu-22.04:docker://ubuntu:22.04" \
--no-interactive
<code">sudo nano /etc/systemd/system/act_runner.service
<code">[Unit] Description=Gitea act_runner After=docker.service network.target [Service] Type=simple User=root WorkingDirectory=/opt/act_runner ExecStart=/usr/local/bin/act_runner daemon --config config.yaml Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target
<code">sudo systemctl daemon-reload sudo systemctl enable act_runner sudo systemctl start act_runner
Step 5: Write Gitea Actions Workflows
<code"># .gitea/workflows/build.yml
# (Same syntax as .github/workflows/ — just different folder name)
name: Build and Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest # Matches runner label
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
docker-build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t myapp:${{ gitea.sha }} .
docker tag myapp:${{ gitea.sha }} myapp:latest
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: |
docker compose -f /opt/myapp/docker-compose.yml up -d --no-deps myapp
echo "Deployed ${{ gitea.sha }}"
Step 6: Secrets Management
<code"># Store secrets in Gitea UI:
# Repository → Settings → Secrets and Variables → Actions → New secret
# Reference in workflow:
steps:
- name: Deploy with SSH
run: |
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
ssh -i /tmp/deploy_key deploy@production.yourdomain.com \
"cd /opt/myapp && docker compose pull && docker compose up -d"
rm /tmp/deploy_key
Step 7: Migrate from GitHub
<code"># Migrate a GitHub repository to Gitea: # Gitea → + → Migrate External Repository → GitHub # Enter: GitHub username/repo, Personal Access Token # Options: migrate wiki, issues, labels, milestones, releases # For .github/workflows/ → rename to .gitea/workflows/ # Most actions work identically; some GitHub-specific actions need alternatives: # actions/upload-artifact → works in Gitea # actions/cache → works in Gitea # GitHub Packages → use Gitea's built-in container registry
Gitea Container Registry
<code"># Gitea includes a container registry (packages)
# Enable in admin: Site Administration → Configuration → Packages
# Push Docker images:
docker login git.yourdomain.com -u username -p password_or_token
docker tag myapp:latest git.yourdomain.com/username/myapp:latest
docker push git.yourdomain.com/username/myapp:latest
# In workflows:
- name: Push to Gitea Registry
run: |
docker login git.yourdomain.com -u ${{ gitea.actor }} -p ${{ secrets.GITEA_TOKEN }}
docker push git.yourdomain.com/${{ gitea.repository }}:${{ gitea.sha }}
Getting Started
Gitea with PostgreSQL uses 200–400 MB RAM. With act_runner (which runs each job in a Docker container), allocate 1–2 GB additional RAM for concurrent builds. A 4 GB Ubuntu VPS at VPS.DO runs Gitea, the database, and handles 2–3 concurrent CI builds comfortably. NVMe storage speeds up Docker layer caching significantly, making subsequent builds much faster.
Conclusion
Gitea with Actions provides a fully self-hosted Git and CI/CD platform with GitHub Actions-compatible YAML syntax — most existing workflows migrate with only folder path changes. For teams prioritizing data sovereignty, cost reduction, or private network CI access, Gitea on a VPS eliminates GitHub/GitLab per-seat fees while maintaining a familiar developer experience. The built-in container registry, issue tracker, and code review complete the development platform.