
Ubuntu 26.04 LTS, codenamed “Resolute Raccoon”, was released on April 23, 2026, with five years of standard maintenance for the server edition. That makes it a sensible base for a self-hosted Git server you plan to keep for years. Gitea is a good fit for that job. It is a single Go binary, it runs happily on a small VPS, and it covers repositories, issues, pull requests, packages and CI through Gitea Actions.
The usual way people end up here is a small frustration that keeps growing. A team starts on a hosted Git platform, then the private repository limits, the pricing tiers or the compliance questions start to bite. Someone says “we could just host it ourselves”, and a weekend later there is a Gitea instance running as root on port 3000 with SQLite and no backups. It works, right up until the first disk fills or the first developer asks why the clone URL has a port number in it.
This guide skips that path. It walks through a proper installation of Gitea on Ubuntu 26.04 LTS: a dedicated system user, PostgreSQL, a hardened systemd service, Nginx as a reverse proxy with Let’s Encrypt, a firewall, backups, and the troubleshooting notes that normally come from a few bad nights on call. The commands target Ubuntu, and the notes call out where Debian or AlmaLinux differ.
One version note first. Gitea dropped the historical “1.” prefix with its 28.0.0 release in September 2026, so current versions look like 28.x rather than 1.x. If you read older tutorials that mention 1.22 or 1.24, the layout and commands below still apply, but check the version string you download.
What You Need Before Starting
Gitea is light, but “light” is relative. For a handful of users and a few dozen repositories, 1 vCPU and 1 GB of RAM will run it. For a team of 20 to 50 developers with CI traffic and larger repositories, plan for 2 vCPUs, 4 GB of RAM and SSD or NVMe storage. Disk I/O matters more than CPU here, because Git operations are mostly small random reads and writes.
Checklist before you begin:
- A fresh Ubuntu 26.04 LTS server with a non-root sudo user.
- A DNS A record (and AAAA if you use IPv6) such as
git.example.compointing at the server. - Ports 22, 80 and 443 reachable from the internet.
- Roughly 20 GB of free disk to start, with a plan to grow.
Confirm the release and bring the system up to date:
lsb_release -a
sudo apt update && sudo apt full-upgrade -y
sudo reboot
The reboot is not ceremony. A fresh image often ships with a kernel older than the one in the archive, and it is far better to find out that the machine fails to boot now than after Gitea has live data.
Choosing an Installation Method
There are three realistic ways to run Gitea on Ubuntu: the official binary, Docker, or a distribution package. Gitea’s own documentation describes the binary route as replacing a single file at /usr/local/bin/gitea when you upgrade, which is exactly why many administrators prefer it.
| Method | Strengths | Weak spots |
|---|---|---|
| Official binary + systemd | Smallest footprint, easy upgrades, full control, simple debugging | You manage the user, units and updates yourself |
| Docker / Compose | Reproducible, easy to move between hosts | Extra layer for SSH passthrough, volume permissions |
| Snap or distro package | Quick to start | Often lags behind upstream, less predictable paths |
This article uses the binary. It keeps the moving parts visible, and when something breaks at 2 AM, visible is what you want.
Step 1: Install Dependencies
Gitea needs Git, and your database server of choice. Nginx and Certbot come later, but installing them now saves a round trip.
sudo apt install -y git curl wget gnupg ca-certificates ufw \
postgresql postgresql-contrib nginx certbot python3-certbot-nginx
Check the Git version:
git --version
Gitea enforces a minimum Git version, and Ubuntu 26.04 ships something comfortably newer. If you ever see a complaint about Git being too old, you are probably on an older base image than you think.
On AlmaLinux or Rocky 9, the equivalent is sudo dnf install -y git postgresql-server nginx certbot python3-certbot-nginx, followed by sudo postgresql-setup --initdb. On Debian, the Ubuntu commands work unchanged.
Step 2: Create a Dedicated System User
Never run Gitea as root. Never run it as your login user either. A dedicated account limits what an attacker can touch if a vulnerability is ever exploited in the web application or in a repository hook.
sudo adduser --system --shell /bin/bash --gecos 'Git Version Control' \
--group --disabled-password --home /home/git git
Two details deserve explanation. The shell is /bin/bash rather than /usr/sbin/nologin because SSH-based Git operations on a host-level sshd need the git account to execute commands. The password is disabled, so nobody can log in with one. Access happens only through SSH keys managed by Gitea.
Step 3: Prepare PostgreSQL
SQLite is fine for a personal instance. For anything shared, PostgreSQL is the better call. Concurrent writes from pushes, webhooks and CI status updates will eventually hit SQLite’s locking, and “database is locked” errors are a miserable thing to debug in production.
Create a role and database:
sudo -u postgres psql
CREATE ROLE gitea WITH LOGIN PASSWORD 'ChangeThisToALongRandomPassword';
CREATE DATABASE giteadb WITH OWNER gitea TEMPLATE template0 ENCODING UTF8 LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8';
\q
Generate the password with openssl rand -base64 32 instead of inventing one. If the en_US.UTF-8 locale is missing on a minimal cloud image, the command fails with a collation error. Fix it with sudo locale-gen en_US.UTF-8 and try again.
Because Gitea and PostgreSQL live on the same machine, connect over a Unix socket or localhost only. Confirm PostgreSQL is not listening publicly:
sudo ss -tlnp | grep 5432
You want to see 127.0.0.1:5432 and nothing on 0.0.0.0. Ubuntu defaults to this, but verify rather than assume, especially on images from third-party providers.
Check how pg_hba.conf treats local TCP connections. Find the file with sudo -u postgres psql -c 'SHOW hba_file;'. The lines for 127.0.0.1/32 should use scram-sha-256, which is the modern default.
Step 4: Download and Install the Gitea Binary
Find the latest stable version on the Gitea downloads page, then set it as a variable. Gitea’s documentation shows the download pattern with dl.gitea.com and a version in the path. Note that older tutorials still point to dl.gitea.io, which is the legacy hostname.
GITEA_VERSION=28.3.0 # replace with the current stable release
cd /tmp
wget -O gitea https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64
wget -O gitea.sha256 https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64.sha256
Verify the checksum before installing anything:
sha256sum gitea
cat gitea.sha256
The two hashes must match. For stronger assurance, Gitea also publishes GPG signatures (.asc files) next to each binary, and verifying those protects you against a compromised mirror. It adds two minutes and removes a whole class of supply-chain worry.
On ARM servers (Ampere, Graviton or a Raspberry Pi), use the linux-arm64 build instead of linux-amd64.
Install it:
sudo install -m 755 gitea /usr/local/bin/gitea
gitea --version
Using install sets ownership and mode in one step. Keep the filename gitea. Gitea’s docs specifically warn that the binary name should not be changed during an update, since existing repository hooks reference it.
Step 5: Create the Directory Structure
Gitea separates its working data from its configuration. Follow that split, because it makes backups and permissions much cleaner.
sudo mkdir -p /var/lib/gitea/{custom,data,log}
sudo chown -R git:git /var/lib/gitea
sudo chmod -R 750 /var/lib/gitea
sudo mkdir /etc/gitea
sudo chown root:git /etc/gitea
sudo chmod 770 /etc/gitea
The config directory is writable by the git group on purpose, but only temporarily. The web installer needs to write app.ini the first time. Once setup finishes, tighten it (covered in the hardening section below). Leaving /etc/gitea group-writable forever is one of the most common slips in Gitea tutorials, and it matters because app.ini contains your secret keys and database password.
Step 6: Create the systemd Service
Running Gitea in a tmux session is how it dies silently on the next reboot. A systemd unit handles startup order, restarts and logging properly.
sudo nano /etc/systemd/system/gitea.service
[Unit]
Description=Gitea (Git with a cup of tea)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
RestartSec=2s
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=false
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
A few choices are worth explaining. After and Requires make sure PostgreSQL is up first, which prevents the classic boot-time race where Gitea starts, fails to reach the database and exits. ProtectHome=false is deliberate: Gitea has to write to /home/git/.ssh/authorized_keys, and turning on ProtectHome would break SSH key registration in a confusing way. LimitNOFILE avoids “too many open files” errors when many clients connect at once.
Resist the urge to add aggressive options such as ProtectSystem=strict without testing. It will block writes outside the paths you whitelist, and Git hooks that touch temporary locations can fail in ways that only show up on push.
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now gitea
sudo systemctl status gitea --no-pager
The service should be active (running). If it is not, jump to the troubleshooting section.
Step 7: Run the Web Installer
Gitea now listens on port 3000. Before exposing it, open an SSH tunnel from your workstation so the setup page never touches the public internet:
ssh -L 3000:127.0.0.1:3000 youruser@git.example.com
Browse to http://localhost:3000. The first visit shows the installation form. Fill it in like this:
- Database type: PostgreSQL
- Host:
127.0.0.1:5432 - Username / password / name: the
gitearole andgiteadbdatabase created earlier - Site title: your organization name
- Repository root path:
/var/lib/gitea/data/gitea-repositories - Run as username:
git - SSH server domain:
git.example.com - SSH port:
22 - Gitea HTTP listen port:
3000 - Gitea base URL:
https://git.example.com/
The base URL is the field people rush through and regret. Gitea uses it to build clone URLs, email links and OAuth callbacks. Entering http://localhost:3000 here is why clone links later show the wrong address. Always use the final public HTTPS URL, even though HTTPS is not set up yet.
Expand the optional settings and create the administrator account now. Disable self-registration if this is an internal server, and consider enabling “Require sign-in to view pages”. The Gitea installer defaults to allowing registration, and an open instance on the internet is a magnet for spam accounts within days.
Click Install. After it completes, lock down the configuration directory:
sudo chmod 750 /etc/gitea
sudo chmod 640 /etc/gitea/app.ini
Then restart the service so it picks up the clean state:
sudo systemctl restart gitea
Step 8: Put Nginx in Front With HTTPS
Gitea can serve TLS itself, but a reverse proxy gives you clean certificate renewal, request buffering, rate limiting and the freedom to host other sites on the same box.
First, bind Gitea to localhost so it is reachable only through Nginx. Edit /etc/gitea/app.ini:
[server]
DOMAIN = git.example.com
ROOT_URL = https://git.example.com/
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
SSH_DOMAIN = git.example.com
Restart Gitea, then create the Nginx site:
sudo nano /etc/nginx/sites-available/gitea
server {
listen 80;
listen [::]:80;
server_name git.example.com;
client_max_body_size 512M;
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_http_version 1.1;
proxy_read_timeout 300;
proxy_request_buffering off;
}
}
Enable it and test:
sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/gitea
sudo nginx -t && sudo systemctl reload nginx
client_max_body_size is the setting behind a famous error. Nginx defaults to 1 MB, and the first time someone pushes a large file or uploads a package over HTTP, they get a 413 Request Entity Too Large. Set it to match your real workloads. proxy_request_buffering off streams large uploads straight to Gitea instead of spooling them to disk in Nginx first, which saves I/O.
Now request a certificate:
sudo certbot --nginx -d git.example.com
Certbot edits the server block, adds the 443 listener and sets up automatic renewal through a systemd timer. Verify renewal works without waiting 60 days to find out:
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
Reload the site in a browser. You should land on the Gitea login page over HTTPS.
Step 9: Configure the Firewall
Ubuntu ships with UFW. Make the rules explicit:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status verbose
Allow OpenSSH before enabling UFW. Skipping that order is the quickest way to lock yourself out of a remote machine, and no one enjoys opening a provider’s recovery console for something avoidable.
Port 3000 stays closed. Because Gitea is bound to 127.0.0.1, even a firewall mistake would not expose it, which is the point of defense in depth. On AlmaLinux, use firewall-cmd --permanent --add-service={http,https,ssh} followed by --reload, and run setsebool -P httpd_can_network_connect 1 so Nginx may proxy to the backend under SELinux.
SSH Access for Git Operations
With the host’s OpenSSH serving Git traffic, users clone with git@git.example.com:org/repo.git. Gitea manages /home/git/.ssh/authorized_keys for you, adding a restricted command for every key a user uploads.
Test from a client machine after adding a public key in the Gitea UI:
ssh -T git@git.example.com
A greeting that includes your Gitea username means it works. If the server asks for a password, the authorized_keys file or permissions are wrong. Check them:
sudo ls -ld /home/git/.ssh
sudo ls -l /home/git/.ssh/authorized_keys
The directory should be 700 and owned by git, the file 600.
To reduce brute-force noise on SSH, disable password logins in /etc/ssh/sshd_config.d/10-hardening.conf:
PasswordAuthentication no
PermitRootLogin no
Then run sudo sshd -t && sudo systemctl reload ssh. Make sure your own key works in a second terminal before you close the current session.
Troubleshooting Common Problems
Service fails to start: “permission denied” on app.ini. Check ownership with ls -l /etc/gitea. The file should be readable by group git. Fix with sudo chown root:git /etc/gitea/app.ini && sudo chmod 640 /etc/gitea/app.ini.
Port 3000 already in use. Run sudo ss -tlnp | grep 3000 to find the culprit. Change HTTP_PORT in app.ini or stop the other service.
502 Bad Gateway from Nginx. Gitea is down or listening elsewhere. Run systemctl status gitea, then curl -I http://127.0.0.1:3000. On AlmaLinux this is frequently SELinux; check sudo ausearch -m avc -ts recent.
Database connection refused or “password authentication failed”. Test manually with psql -h 127.0.0.1 -U gitea giteadb. If that fails, the problem is in PostgreSQL, not Gitea. Review pg_hba.conf and reload with sudo systemctl reload postgresql.
Clone URL shows localhost:3000. The ROOT_URL is wrong. Fix it in app.ini and restart the service.
SSH clone works for admin but not other users, or “Permission denied (publickey)”. Inspect /home/git/.ssh/authorized_keys permissions, then run sudo -u git /usr/local/bin/gitea admin regenerate keys -c /etc/gitea/app.ini to rebuild the file from the database.
Git hooks fail after moving the binary. Hooks embed the binary path. Run sudo -u git /usr/local/bin/gitea admin regenerate hooks -c /etc/gitea/app.ini.
413 on push or upload. Raise client_max_body_size in Nginx and reload.
High CPU after upgrade. Often the indexer. Repository and issue indexing run in the background after major upgrades; check progress in the admin panel’s monitoring page and give it time before blaming anything else.
For all of these, the habit that saves the most time is reading logs in the right order: journalctl -u gitea -e, then /var/lib/gitea/log/gitea.log, then /var/log/nginx/error.log, then journalctl -u postgresql.