Hi, I finally carved out an hour to replace the dingy pile of bare Git repos sitting on my NAS with a proper forge. Gitea 28.3.0 dropped on 1 November 2026 and the timing was perfect — the project just shed the old
1. prefix from its version numbers, marks the 28.x series. One static binary under 120 MB, SQLite baked in, systemd unit, and you have pull requests, issues, CI and a Docker registry — all for the price of apt get and wget. I ran through the install on Ubuntu 26.04 and everything below is what actually happened.
Sources: docs.gitea.com · github.com/go-gitea/gitea · dl.gitea.com

What you need
- A 64-bit Ubuntu 24.04 or 26.04 server (or anything with systemd and AppArmor/SELinux)
- Git 2.25 or newer — Gitea 28 refuses to start with an older git (I found this the hard way)
- Port 3000 (web UI) and 3022 (built-in SSH) open, or pick your own
- root/sudo access for package install and service creation
Step 1 — Create the git service user
Gitea recommends not running as root. Create a dedicated system user and group:
adduser --system --shell /bin/bash --gecos 'Gitea Version Control' \
--group --disabled-password --home /home/git git
id gituid=104(git) gid=107(git) groups=107(git)Step 2 — Data directories
Create the directory skeleton Gitea expects. The /etc/gitea directory is world-writable temporarily so the web installer can write the config, but since we will pre-create the config file we can lock it down later.
mkdir -p /var/lib/gitea/{custom,data,log}
chown -R git:git /var/lib/gitea/
chmod -R 750 /var/lib/gitea/
mkdir /etc/gitea
chown root:git /etc/gitea
chmod 770 /etc/giteadrwxrwx--- 2 root git 4096 Nov 3 03:02 /etc/gitea
drwxr-x--- 5 git git 4096 Nov 3 03:02 /var/lib/giteaStep 3 — Download the binary and verify checksums
Download from the official directory. The SHA-256 file references the original filename, not gitea — keep the original name or you will see FAILED open or read. Rename back with a symlink once verified.
wget https://dl.gitea.com/gitea/28.3.0/gitea-28.3.0-linux-amd64
wget https://dl.gitea.com/gitea/28.3.0/gitea-28.3.0-linux-amd64.sha256
sha256sum -c gitea-28.3.0-linux-amd64.sha256
ln -sf gitea-28.3.0-linux-amd64 gitea
chmod +x giteagitea-28.3.0-linux-amd64: OKThe binary itself comes sigstore-signed (check the .sigstore.json bundle if you prefer cosign).
Step 4 — Install the binary and test
Copy to /usr/local/bin and check the version:
cp gitea /usr/local/bin/gitea
gitea --versiongitea version 28.3.0 built with go1.27.3Step 5 — Configuration and secrets
Generate three secret keys with gitea generate secret. This sub-command refuses to run as root (Gitea checks mustNotRunAsRoot), so use sudo -u git or su. I hit a crash on the first migrate run because the JWT secret was missing — prepopulate [oauth2] JWT_SECRET now. Write the config file:
S=$(sudo -S -p -u git gitea generate secret SECRET_KEY)
I=$(sudo -S -p -u git gitea generate secret INTERNAL_TOKEN)
J=$(sudo -S -p -u git gitea generate secret JWT_SECRET)
cat > /etc/gitea/app.ini <
APP_NAME = Gitea
RUN_USER = git
RUN_MODE = prod
[server]
PROTOCOL = http
DOMAIN = localhost
HTTP_PORT = 3000
ROOT_URL = http://localhost:3000/
START_SSH_SERVER = true
SSH_PORT = 3022
[database]
DB_TYPE = sqlite3
PATH = /var/lib/gitea/data/gitea.db
[repository]
ROOT = /home/git/gitea-repositories
[security]
INSTALL_LOCK = true
SECRET_KEY = $S
INTERNAL_TOKEN = $I
[oauth2]
JWT_SECRET = $J
[service]
DISABLE_REGISTRATION = true
EOF
chown root:git /etc/gitea/app.ini
chmod 640 /etc/gitea/app.iniThe INSTALL_LOCK=true and DISABLE_REGISTRATION = true mimic the hardened defaults of 28.x. Self-registration is off unless you explicitly set it to false.
Step 6 — Run the database migration
Gitea will initialise the SQLite database and verify the schema is sound:
sudo -u git GITEA_WORK_DIR=/var/lib/gitea \
/usr/local/bin/gitea migrate -c /etc/gitea/app.ini2026/11/03 03:04:31 cmd/migrate.go:31:runMigrate() [I] AppPath: /usr/local/bin/gitea
2026/11/03 03:04:31 cmd/migrate.go:31:runMigrate() [I] AppWorkPath: /var/lib/gitea
2026/11/03 03:04:31 cmd/migrate.go:31:runMigrate() [I] Custom path: /var/lib/gitea/custom
2026/11/03 03:04:31 cmd/migrate.go:31:runMigrate() [I] Log path: /var/lib/gitea/log
2026/11/03 03:04:31 cmd/migrate.go:31:runMigrate() [I] Configuration file: /etc/gitea/app.ini
2026/11/03 03:04:31 .../[email protected]/command_run.go:95:(*Command).Run() [I] PING DATABASE sqlite3Step 7 — systemd service
The official unit uses a private mount namespace and no-new-privileges. Create /etc/systemd/system/gitea.service:
[Unit]
Description=Gitea (Git with a cup of tea)
After=syslog.target
After=network.target
[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
MountFlags=private
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
[Install]
WantedBy=multi-user.targetEnable and start it:
systemctl daemon-reload
systemctl enable gitea --now
systemctl is-active gitea && systemctl is-enabled giteaactive
enabledStep 8 — Create an admin user
With the service running, create the initial admin via the command line. Again, run as the git user:
sudo -u git GITEA_WORK_DIR=/var/lib/gitea /usr/local/bin/gitea admin user create \
--username admin --password 'ChangeMe!2026' \
--email [email protected] --admin \
--config /etc/gitea/app.iniNew user 'admin' has been successfully created!Step 9 — Generate an access token for API usage
To create repositories via the API you need a token with the write:user scope. A token with only write:repository returns a 403 — found that the hard way.
sudo -u git GITEA_WORK_DIR=/var/lib/gitea /usr/local/bin/gitea admin user \
generate-access-token --username admin --token-name demo2 \
--scopes write:repository,write:user \
--config /etc/gitea/app.iniAccess token was successfully created: aa54b951bf4b8a84b6ccc8a3ff24ae29a90d1118Step 10 — Push a test repository
Now create a repo via the API, make a local commit and push over HTTP. Gitea 28 initialises new repos with main as the default branch so I used git init -b main:
TOKEN=aa54b951bf4b8a84b6ccc8a3ff24ae29a90d1118
curl -s -X POST http://localhost:3000/api/v1/user/repos \
-H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"demo-app","auto_init":false,"private":false}'201
admin/demo-app http://localhost:3000/admin/demo-app.gitClone and push:
D=$(mktemp -d) && cd $D && git init -q -b main pushdemo
git config user.email [email protected] && git config user.name pamy
git commit -q --allow-empty -m "first commit"
git push -q http://admin:$TOKEN@localhost:3000/admin/demo-app.git main82ad2ce3901f0aa32df4b6a85e60133a9b79d05b HEAD
82ad2ce3901f0aa32df4b6a85e60133a9b79d05b refs/heads/mainHTTP basic auth with the token worked on the first try. The built-in SSH server on port 3022 is also available for the more security-conscious.
Verification
Check the web UI responds, read the version endpoint, and confirm both web and SSH listeners are up:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
curl -s http://localhost:3000/api/v1/version200
{"version":"28.3.0"}ss -tlnp | grep -E '3000|3022'LISTEN 0 4096 *:3022 *:* users:(("gitea",pid=106959,fd=14))
LISTEN 0 4096 *:3000 *:* users:(("gitea",pid=106959,fd=15))After installation, lock down /etc/gitea to read-only and restart:
chmod 750 /etc/gitea
chmod 640 /etc/gitea/app.ini
systemctl restart gitea
echo "Homepage: $(curl -s -o /dev/null -w %{http_code} http://localhost:3000/)"Homepage: 200The locked-down service still returns 200 and the startup log shows no errors:
journalctl -u gitea --no-pager -n 4Nov 03 03:06:23 gitea[106959]: [I] Starting new SSH server: tcp::3022
Nov 03 03:06:23 gitea[106959]: [I] Starting new Web server: tcp:0.0.0.0:3000
Nov 03 03:06:23 gitea[106959]: [I] Listen: http://0.0.0.0:3000
Nov 03 03:06:25 gitea[106959]: [I] router: completed GET / for [::1], 200 OK in 7.4ms
Gotchas I hit during this walkthrough
Problem Cause / Fix sha256sum: FAILED stop open or readsha256sum file references the full filename e.g. gitea-28.3.0-linux-amd64. Keep the original name and symlink it.migrate: failed to save /etc/gitea/app.ini (permission denied)Gitea auto-writes the JWT_SECRET on load if it is missing from [oauth2]. Pre-generate it with gitea generate secret JWT_SECRET.Gitea is not supposed to be run as rootThe gitea admin and gitea generate secret commands refuse to run as root. Use sudo -u git ... or su -s /bin/bash git -c "..."API returns 403 token does not have at least one of required scope(s)Creating repos via API needs write:user scope, not just write:repository. Regenerate the token with --scopes write:repository,write:user.src refspec main does not match anyYour local git init defaulted to master. Pass git init -b main or push master instead.No self-registration available on the web UI Gitea 28 disables registration by default. Set DISABLE_REGISTRATION = false in [service] if you need open sign-ups.
That is it. Gitea 28 is a single binary with no runtime dependencies — sqlite, systemd, one config file and you own a full forge. I found it noticeably lighter than GitLab to keep running on a homelab VM. The new version numbering took a minute to get used to, but the release cadence is solid. If you have been meaning to self-host a Git platform, grab the weekend and give it a shot.