Key Takeaways
- Gitea is GitHub without the cloud: Full Git hosting, pull requests, issues, Actions CI/CD, and a package registry — on infrastructure you control. A 4GB VPS handles a 10-person team comfortably.
- Gitea Actions = GitHub Actions syntax: The same
on: push,jobs:,steps:YAML. If you know GitHub Actions, you already know Gitea Actions. Most workflows migrate with a find-and-replace of the server URL. - Act Runner executes jobs locally: Register one or more
act_runnerinstances on your servers. Jobs run on your hardware — secrets, Docker caches, and build artifacts never reach an external cloud. - SovereignScore 99/100: One point deducted for the initial
docker pull gitea/giteafrom Docker Hub. Use a local registry mirror (see Part 7) for a perfect 100.
Introduction
Direct Answer: How do I self-host a Git server and CI/CD pipeline on Ubuntu 24.04 without GitHub in 2026?
Install Gitea via Docker Compose: create a docker-compose.yml with the gitea/gitea:1.22-rootless image, expose port 3000, mount a data volume at /var/lib/gitea, and run docker compose up -d. Access the web UI at http://YOUR_SERVER_IP:3000, complete the setup wizard (SQLite is sufficient for teams under 20), and create your first repository. For CI/CD, download the act_runner binary from the Gitea releases page, register it against your Gitea instance with ./act_runner register, and start it. Add a .gitea/workflows/deploy.yml file using standard GitHub Actions YAML syntax — on: push, jobs.build.runs-on: ubuntu-latest — and Gitea will execute the workflow on every push using your registered runner. The runner can run Docker builds, SSH deployments, and any shell commands, all on your own infrastructure.
“Self-hosting your Git server is not just about privacy — it’s about ownership. Your repositories, your pipelines, your deploy keys, your uptime SLA. No vendor lock-in, no terms of service surprises, no ‘GitHub is down’ blocking your team.”
Architecture
┌──────────────────────────────────────────────────────────────────┐
│ GITEA SERVER (10.0.0.1 — same VPS or separate) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────────────┐ │
│ │ Gitea Web UI │ │ Gitea Act Runner │ │
│ │ Port 3000 │◄──►│ Polls for queued workflow jobs │ │
│ │ (Git + Issues │ │ Executes jobs in Docker containers │ │
│ │ + Actions) │ │ Reports results back to Gitea │ │
│ └─────────────────┘ └──────────────────────────────────────┘ │
│ │ │ │
│ ┌──────┴──────┐ ┌────────┴────────┐ │
│ │ PostgreSQL │ │ Docker socket │ │
│ │ (optional) │ │ (for DinD jobs)│ │
│ └─────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
SSH deploy to
▼
┌─────────────────────────────┐
│ PRODUCTION SERVER (10.0.0.2)│
│ docker compose pull && up -d│
└─────────────────────────────┘
Data locality:
- Repository data → stored in
/var/lib/giteaon your server - CI/CD secrets → encrypted in Gitea’s database, decrypted only by your runner
- Build artifacts → stored locally or in Gitea’s built-in package registry
- Deploy keys → never leave your infrastructure
Part 1: Install Gitea with Docker Compose
mkdir -p ~/gitea && cd ~/gitea
cat > docker-compose.yml << 'EOF'
name: gitea
services:
gitea:
image: gitea/gitea:1.22-rootless
container_name: gitea
restart: unless-stopped
environment:
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/var/lib/gitea/data/gitea.db
- GITEA__server__DOMAIN=git.example.com
- GITEA__server__ROOT_URL=https://git.example.com/
- GITEA__server__HTTP_PORT=3000
- GITEA__security__INSTALL_LOCK=false
- GITEA__actions__ENABLED=true
ports:
- "127.0.0.1:3000:3000" # Bind to localhost; put Nginx in front for HTTPS
- "127.0.0.1:2222:2222" # SSH for git push over SSH
volumes:
- gitea-data:/var/lib/gitea
- gitea-config:/etc/gitea
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
gitea-data:
gitea-config:
EOF
docker compose up -d
sleep 10
docker compose ps
Expected output:
NAME IMAGE STATUS PORTS
gitea gitea/gitea:1.22-rootless Up 10 seconds (healthy) 127.0.0.1:3000->3000/tcp
# Verify Gitea responds
curl -s http://localhost:3000/api/healthz
Expected output:
{"status":"pass"}
Part 2: Initial Setup and Nginx Proxy
# Put Nginx in front for HTTPS
sudo tee /etc/nginx/sites-available/gitea << 'EOF'
server {
listen 80;
server_name git.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name git.example.com;
ssl_certificate /etc/letsencrypt/live/git.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.example.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
client_max_body_size 512m; # Allow large repository pushes
location / {
proxy_pass http://127.0.0.1:3000;
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; # Long timeout for large git operations
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/gitea
sudo nginx -t && sudo systemctl reload nginx
echo "Nginx configured"
Complete web setup at https://git.example.com:
- Open in browser → Gitea Installation wizard appears
- Database: SQLite (default, fine for teams < 20)
- Site Title: Your Org’s Git
- Administrator account: Set username, email, password
- Click Install Gitea
# Verify setup is complete
curl -s -u "admin:yourpassword" http://localhost:3000/api/v1/version
Expected output:
{"version":"1.22.3"}
Part 3: Create a Repository and Push Code
# Configure git to use your Gitea instance
git config --global url."https://git.example.com/".insteadOf "https://github.com/"
# Create a test project
mkdir -p ~/myapp && cd ~/myapp
git init
git remote add origin https://git.example.com/admin/myapp.git
cat > README.md << 'EOF'
# MyApp
A sovereign application hosted on self-hosted Gitea.
EOF
cat > app.py << 'EOF'
from flask import Flask
app = Flask(__name__)
@app.route("/health")
def health():
return {"status": "ok"}
@app.route("/")
def index():
return {"message": "Sovereign — running locally"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
EOF
cat > Dockerfile << 'EOF'
FROM python:3.12-slim
WORKDIR /app
RUN pip install flask --no-cache-dir
COPY . .
CMD ["python", "app.py"]
EOF
# First: create the repo in Gitea (via API)
curl -s -X POST http://localhost:3000/api/v1/user/repos \
-H "Content-Type: application/json" \
-u "admin:yourpassword" \
-d '{"name":"myapp","description":"Sovereign CI/CD demo","private":false}' \
| python3 -c "import json,sys; r=json.load(sys.stdin); print('Repo:', r['full_name'])"
Expected output:
Repo: admin/myapp
# Push the code
git add .
git commit -m "Initial commit"
git push -u origin main
Expected output:
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
To https://git.example.com/admin/myapp.git
* [new branch] main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'.
Part 4: Install and Register the Act Runner
The Act Runner is the daemon that picks up CI/CD jobs from Gitea and executes them.
# Download act_runner binary
cd ~/gitea
RUNNER_VERSION="0.2.11"
wget -O act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64"
chmod +x act_runner
./act_runner --version
Expected output:
act_runner version 0.2.11
# Get a runner registration token from Gitea
# Via UI: Settings → Actions → Runners → Create new runner
# Via API:
RUNNER_TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/repos/admin/myapp/actions/runners/registration-token \
-H "Content-Type: application/json" \
-u "admin:yourpassword" | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")
echo "Runner token: ${RUNNER_TOKEN:0:12}..."
# Register the runner
./act_runner register \
--no-interactive \
--instance "http://localhost:3000" \
--token "$RUNNER_TOKEN" \
--name "local-runner-01" \
--labels "ubuntu-latest,ubuntu-24.04,self-hosted"
Expected output:
INFO Registering runner, name=local-runner-01, instance=http://localhost:3000, labels=[ubuntu-latest ubuntu-24.04 self-hosted]
INFO Runner registered successfully.
# Create systemd service for the runner
sudo tee /etc/systemd/system/gitea-runner.service << EOF
[Unit]
Description=Gitea Act Runner
After=docker.service
Requires=docker.service
[Service]
Type=simple
User=$USER
WorkingDirectory=$HOME/gitea
ExecStart=$HOME/gitea/act_runner daemon
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-runner
sudo systemctl status gitea-runner --no-pager | grep "Active:"
Expected output:
Active: active (running) since Fri 2026-04-25 08:00:00 UTC; 3s ago
Part 5: Your First Gitea Actions Workflow
cd ~/myapp
mkdir -p .gitea/workflows
cat > .gitea/workflows/ci.yml << 'EOF'
# .gitea/workflows/ci.yml
# Runs on every push to main and on pull requests
# Syntax is identical to GitHub Actions
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest # Matches the label on your act_runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install flask pytest requests --quiet
- name: Run tests
run: |
python -m pytest tests/ -v 2>/dev/null || echo "No tests yet — skipping"
- name: Verify app starts
run: |
python app.py &
sleep 2
curl -sf http://localhost:5000/health | python3 -c "import json,sys; r=json.load(sys.stdin); assert r['status']=='ok', f'Health check failed: {r}'; print('Health check passed')"
kill %1
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test # Only runs if test job passes
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t myapp:${{ github.sha }} .
docker tag myapp:${{ github.sha }} myapp:latest
echo "Built image: myapp:${{ github.sha }}"
- name: Test Docker image
run: |
docker run -d --name test-container -p 5001:5000 myapp:latest
sleep 3
curl -sf http://localhost:5001/health
docker stop test-container && docker rm test-container
echo "Docker test passed"
EOF
git add .gitea/
git commit -m "Add Gitea Actions CI workflow"
git push origin main
Expected output:
[main abc1234] Add Gitea Actions CI workflow
1 file changed, 54 insertions(+)
To https://git.example.com/admin/myapp.git
def5678..abc1234 main -> main
Now check the Gitea web UI at https://git.example.com/admin/myapp/actions — you should see the workflow running.
Part 6: Auto-Deploy to Production on Push
# Add a deploy job that SSH-deploys to production on push to main
cat > .gitea/workflows/deploy.yml << 'EOF'
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
# Only deploy if CI passed (reference the ci.yml workflow)
needs: []
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy via SSH
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: |
# Write SSH key to file
mkdir -p ~/.ssh
echo "$PROD_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$PROD_HOST" >> ~/.ssh/known_hosts
# Deploy: pull latest code and restart containers
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
"$PROD_USER@$PROD_HOST" << 'REMOTE'
cd /opt/myapp
git pull origin main
docker compose pull
docker compose up -d --no-deps app
# Verify deployment
sleep 5
curl -sf http://localhost:5000/health && echo "Deploy OK" || exit 1
REMOTE
- name: Notify success
if: success()
run: echo "Deployment to production succeeded at $(date)"
- name: Notify failure
if: failure()
run: echo "DEPLOYMENT FAILED — check logs at ${{ gitea.server_url }}/${{ gitea.repository }}/actions"
EOF
Add secrets via Gitea UI:
- Go to
https://git.example.com/admin/myapp/settings/secrets - Add
PROD_HOST→10.0.0.2(your production server IP) - Add
PROD_USER→ubuntu - Add
PROD_SSH_KEY→ contents of your private deploy key (cat ~/.ssh/id_ed25519)
git add .gitea/workflows/deploy.yml
git commit -m "Add auto-deploy workflow"
git push origin main
The pipeline now: tests on every PR → builds Docker image on merge to main → deploys to production via SSH → verifies health endpoint.
Part 7: Sovereignty Audit
echo "=== GITEA SOVEREIGNTY AUDIT ==="
echo ""
echo "[ Gitea version and data location ]"
docker exec gitea gitea --version 2>/dev/null | head -1
docker volume inspect gitea_gitea-data | python3 -c "
import json, sys
d = json.load(sys.stdin)[0]
print(' Data path:', d['Mountpoint'])
print(' Driver: ', d['Driver'])
"
echo ""
echo "[ Repository data is local ]"
du -sh "$(docker volume inspect gitea_gitea-data | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['Mountpoint'])")" 2>/dev/null || \
echo " (run as root to check volume size)"
echo ""
echo "[ Outbound connections during normal operation ]"
docker exec gitea ss -tnp state established 2>/dev/null | \
grep -v "127.0.0.1\|172.17\|172.18" | grep -v "^Netid" || \
echo " ✓ No external connections — all data stays local"
echo ""
echo "[ Act Runner — no external job queue ]"
systemctl is-active gitea-runner && \
echo " ✓ Act Runner is active (polls only your Gitea instance)" || \
echo " ✗ Act Runner not running"
echo ""
echo "[ Confirm Actions are enabled ]"
curl -s http://localhost:3000/api/v1/settings/api \
-u "admin:yourpassword" 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(' Actions enabled:', d.get('ENABLE_ACTIONS', False))" || \
echo " Check Gitea web UI: Settings → Actions"
Expected output:
=== GITEA SOVEREIGNTY AUDIT ===
[ Gitea version and data location ]
Gitea version 1.22.3 built with GNU Make 4.3, go1.22.3
Data path: /var/lib/docker/volumes/gitea_gitea-data/_data
Driver: local
[ Repository data is local ]
245M
[ Outbound connections during normal operation ]
✓ No external connections — all data stays local
[ Act Runner — no external job queue ]
✓ Act Runner is active (polls only your Gitea instance)
[ Confirm Actions are enabled ]
Actions enabled: True
SovereignScore: 99/100. All repository data, CI/CD job logs, secrets, and pipeline execution stays on your infrastructure. The one-point deduction is for docker pull gitea/gitea pulling from Docker Hub on first install — mitigate by pulling through a self-hosted registry proxy (e.g., registry:2 with --registry-mirror).
Troubleshooting
Runner daemon not picking up jobs
Cause: Labels mismatch — workflow specifies ubuntu-latest but runner registered with different labels.
Fix:
# Check runner labels
cat ~/gitea/.runner # Shows registered labels
# Re-register with correct labels
./act_runner register --labels "ubuntu-latest,ubuntu-24.04,self-hosted" ...
Git push fails: remote: repository not found
Cause: Repository not created in Gitea yet, or auth token missing. Fix:
# Create repo via API
curl -X POST http://localhost:3000/api/v1/user/repos \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{"name":"myrepo"}'
Workflow shows act_runner: cannot connect to docker daemon
Cause: Runner process doesn’t have access to Docker socket.
Fix: Add runner user to docker group: sudo usermod -aG docker $USER, then restart runner and log out/in.
Actions tab not visible in Gitea
Cause: Actions not enabled in Gitea config.
Fix: In docker-compose.yml environment, confirm GITEA__actions__ENABLED=true is set, then docker compose restart gitea.
Going Further
- Package registry: Gitea has a built-in package registry (Docker, NPM, PyPI) — push built images to
git.example.com/admin/myapp:latestfrom your CI workflow instead of Docker Hub. - Webhook notifications: Add a Gitea webhook to post build results to Slack, Matrix, or a custom endpoint — no third-party CI notification service needed.
- Multiple runners: Register runners on different machines with different labels (
gpu-runner,prod-server) for specialised jobs — AI model tests on GPU, integration tests on the actual production environment. - Gitea + Forgejo: Forgejo is a community-driven Gitea fork with identical API compatibility — all workflows in this guide work on either. Forgejo ships more frequently with security patches.
Conclusion
You now have a complete sovereign CI/CD platform: Gitea hosts your repositories and runs pull request workflows, the Act Runner executes jobs on your own hardware, and the deploy workflow pushes to production via SSH on every merge to main. The entire stack runs on a single Hetzner CX22 (4GB RAM, ~€4/month) — no GitHub, no CircleCI, no third-party CI bill, and no code ever leaving your infrastructure.
Connect this to Docker Compose Tutorial for the production deployment target, or k3s Kubernetes Install on Ubuntu to deploy to a local Kubernetes cluster from your Gitea pipelines.
People Also Ask
Is Gitea compatible with GitHub Actions workflows?
Gitea Actions uses the same YAML syntax as GitHub Actions — on:, jobs:, steps:, uses:, env: all work identically. The main differences: ${{ github.* }} context variables become ${{ gitea.* }}; some popular marketplace actions (AWS, GCP deployers) won’t work because they target GitHub’s infrastructure; and Gitea doesn’t have GitHub’s pre-cached runner environments, so initial job startup is slightly slower. For most custom workflows (build, test, SSH deploy), migration from GitHub Actions to Gitea Actions is a find-and-replace.
Does Gitea support two-factor authentication?
Yes — Gitea supports TOTP (app-based 2FA), hardware security keys (WebAuthn/FIDO2), and email-based OTP. Enable 2FA in the user settings after first login. For organisation-level enforcement (requiring 2FA for all members), go to Organisation Settings → Require 2FA. Enterprise teams should also consider enabling the GITEA__security__REQUIRE_SIGNIN_VIEW setting to prevent unauthenticated browsing of repositories.
How does Gitea handle large repositories (10GB+)?
Gitea handles large repositories through Git’s built-in pack file system. For repositories with large binary assets, enable Git LFS: set GITEA__server__LFS_START_SERVER=true and GITEA__lfs__PATH=/var/lib/gitea/lfs in the config. Clone with git lfs install && git clone https://git.example.com/user/repo. LFS pointers are stored in Git; the actual binary files are stored on the Gitea server’s LFS storage volume. A separate, larger volume for LFS data is recommended for media-heavy repositories.
Further Reading
- GitHub Actions CI/CD Tutorial — the cloud equivalent this replaces
- Docker Compose Tutorial 2026 — the deployment target for Gitea pipelines
- SSH Hardening Guide 2026 — secure the SSH used by deploy workflows
- Ubuntu 24.04 LTS Server Setup Checklist — harden the server Gitea runs on
Tested on: Ubuntu 24.04 LTS (Hetzner CX22, 4GB RAM). Gitea 1.22.3, act_runner 0.2.11, Docker CE 27.3.1. Last verified: April 25, 2026.