Self-Hosted Git Server on a VPS: Gitea Setup Guide
If you're paying per-seat for private repos on GitHub or GitLab.com just to keep a few client projects out of public view, a self-hosted Git server on your own VPS solves that for the cost of a small droplet. Gitea is the tool most people reach for — it's a single Go binary, uses a fraction of the RAM GitLab needs, and gets you push/pull, issues, pull requests, and a web UI in about fifteen minutes.
Why Gitea instead of GitLab or Gogs
GitLab CE is powerful but heavy — realistically you want 4GB+ RAM just for it to start reliably, and upgrades between versions can be a whole afternoon. Gogs is where Gitea forked from, but the fork has pulled ahead in maintenance and features. For a VPS in the 1-2 vCPU / 2GB RAM range — which covers most small hosting plans — Gitea is the practical choice. It runs comfortably alongside a couple of small web apps on the same box.
What you need before you start
- A VPS running Ubuntu 22.04/24.04 or AlmaLinux 9, with a non-root sudo user already set up
- Docker and Docker Compose installed (
docker compose versionshould return something) - A subdomain pointed at the VPS, e.g.
git.yourdomain.com— an A record to the VPS's IP - Ports 80 and 443 open (443 for HTTPS, 80 for the Let's Encrypt challenge)
If Docker isn't installed yet: curl -fsSL https://get.docker.com | sh then add your user to the docker group with sudo usermod -aG docker $USER and log back in for it to take effect.
Step 1: Lay out the directories
mkdir -p ~/gitea/{data,config}
cd ~/gitea
Keeping data and config outside the container means an upgrade is just pulling a new image — nothing you care about lives inside the container itself.
Step 2: Write the Docker Compose file
cat > docker-compose.yml <<'EOF'
version: "3"
services:
gitea:
image: gitea/gitea:1.22
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__server__DOMAIN=git.yourdomain.com
- GITEA__server__ROOT_URL=https://git.yourdomain.com/
- GITEA__server__SSH_PORT=2222
restart: unless-stopped
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000"
- "2222:22"
EOF
Two things worth noting here. First, Gitea only binds to 127.0.0.1:3000 — it's never exposed directly to the internet, Nginx sits in front of it. Second, SSH runs on host port 2222 instead of 22, because your VPS's real SSH daemon is already using 22. Clone URLs will look like ssh://git@git.yourdomain.com:2222/user/repo.git — a minor inconvenience worth the clarity of not touching your system SSH config.
Bring it up:
docker compose up -d
docker compose logs -f gitea # ctrl-C once you see it's listening
Step 3: Put Nginx in front of it with HTTPS
If you don't already have Nginx and Certbot on the VPS:
sudo apt install nginx certbot python3-certbot-nginx -y
Create /etc/nginx/sites-available/gitea:
server {
listen 80;
server_name git.yourdomain.com;
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;
client_max_body_size 512M;
}
}
The client_max_body_size line matters more than it looks — the default 1MB limit will reject pushes with large binary blobs (design assets, video fixtures) with a cryptic 413 from Nginx rather than a Git error, which is confusing to debug later.
sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d git.yourdomain.com
Certbot rewrites the server block to redirect port 80 to 443 and installs the cert. Visit https://git.yourdomain.com and you should hit Gitea's install wizard.
Step 4: Finish the install wizard correctly
Gitea's setup page will ask for a database — for anything beyond a couple of users, pick SQLite only if you're testing; for real use, run a small Postgres or MySQL container alongside it and point Gitea at that instead. The fields that trip people up:
- SSH Server Domain — set to
git.yourdomain.com, same as the web domain - SSH Port — must match the host port you mapped (2222 in the example above), not 22
- Gitea Base URL — must be the full HTTPS URL, not http, or generated clone URLs will be wrong
After the wizard, register your admin account immediately — Gitea allows open registration by default, and the first account created isn't automatically an admin unless you check the box on that first signup form.
Step 5: Lock down registration
Unless you're running a public Git host, disable open sign-ups. In ~/gitea/config/gitea/gitea.ini, under [service]:
DISABLE_REGISTRATION = true
Then docker compose restart gitea. From here, you invite users manually from the admin panel or they use the SSH keys you hand out — no public sign-up form sitting on the internet.
Clone, push, and day-to-day use
# over HTTPS
git clone https://git.yourdomain.com/yourname/project.git
# over SSH (note the custom port)
git clone ssh://git@git.yourdomain.com:2222/yourname/project.git
Add your public key under Settings → SSH Keys in the web UI, and pushes work exactly like GitHub. CI runners (GitHub Actions self-hosted, Drone, Woodpecker) can all point at this same server if you outgrow the built-in Gitea Actions.
Backups — don't skip this
Everything that matters lives in ~/gitea/data plus your database. A daily cron that snapshots both is enough for most setups:
#!/bin/bash
docker exec gitea gitea dump -c /data/gitea/conf/app.ini -f /data/backup-$(date +%F).zip
find ~/gitea/data -name "backup-*.zip" -mtime +7 -delete
Ship that zip somewhere off the VPS — object storage, another server, wherever your other backups go. A Git server with no backup is the one piece of infrastructure people always assume is "fine" right up until the disk that hosts it isn't.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
| SSH clone hangs or refuses | Port 2222 not open in the firewall, or SSH_PORT mismatch between compose file and gitea.ini | Open the port with UFW/firewalld and make sure both values match |
| Web UI loads but pushes over HTTPS get 502 | Container not actually listening, or Nginx pointed at the wrong port | docker compose logs gitea, confirm it's up, check proxy_pass target |
| Large repo push fails with 413 | Nginx client_max_body_size too low | Raise the limit in the server block and reload Nginx |
| Clone URL shows http instead of https | ROOT_URL set to http:// during install | Fix ROOT_URL in gitea.ini and restart the container |
Prevention
Pin the Gitea image version in your compose file (as above) rather than tracking latest — Gitea does occasionally ship breaking config changes between major versions, and you want to read the release notes before you're mid-upgrade on a Friday. Keep the database and data directory backups running before you have real repos on the box, not after someone loses a week of commits because the disk filled up.
Frequently asked questions
Can I run Gitea without Docker, directly on the VPS?
Yes — Gitea ships as a single static binary you can run with a systemd service and a local MySQL/Postgres install. Docker just makes upgrades and backups more predictable, which is why most guides (including this one) lead with it.
How much VPS is enough to run Gitea for a small team?
1 vCPU and 1-2GB RAM handles a handful of users and moderate-sized repos comfortably. Bump to 2 vCPU / 4GB if you'll also run Gitea Actions (CI) on the same box, since builds are what actually eat CPU.
Is Gitea a real alternative to GitHub for private work, or just a toy?
It's used in production by plenty of small teams and even some larger organizations that want full control over their code. You lose GitHub's ecosystem (Copilot, Actions marketplace, Dependabot) but keep pull requests, issues, wikis, webhooks, and now built-in Actions.
Can I migrate existing GitHub repos into Gitea?
Yes — Gitea has a built-in 'Migrate Repository' option in the web UI that pulls directly from a GitHub/GitLab URL, including issues and PRs if you supply a personal access token. No local clone-and-push dance required.
Do I need a separate database, or is SQLite fine long-term?
SQLite works for solo use or light testing, but it doesn't handle concurrent writes well under real team load. For anything beyond one or two active users, run Postgres or MySQL as a second container from day one — migrating later is possible but adds an avoidable step.