Install Gitea on Ubuntu 26.04

– min read

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


Gitea logo with wordmark
Gitea logo by Lauris BH, Wikimedia Commons (MIT license)


What you need

  1. A 64-bit Ubuntu 24.04 or 26.04 server (or anything with systemd and AppArmor/SELinux)
  2. Git 2.25 or newer — Gitea 28 refuses to start with an older git (I found this the hard way)
  3. Port 3000 (web UI) and 3022 (built-in SSH) open, or pick your own
  4. 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 git


uid=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/gitea


drwxrwx--- 2 root git 4096 Nov  3 03:02 /etc/gitea
drwxr-x--- 5 git  git 4096 Nov  3 03:02 /var/lib/gitea


Step 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 gitea


gitea-28.3.0-linux-amd64: OK

The 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 --version


gitea version 28.3.0 built with go1.27.3


Step 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.ini


The 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.ini


2026/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 sqlite3


Step 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.target


Enable and start it:

systemctl daemon-reload
systemctl enable gitea --now
systemctl is-active gitea && systemctl is-enabled gitea


active
enabled


Step 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.ini


New 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.ini


Access token was successfully created: aa54b951bf4b8a84b6ccc8a3ff24ae29a90d1118


Step 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.git


Clone 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 main


82ad2ce3901f0aa32df4b6a85e60133a9b79d05b	HEAD
82ad2ce3901f0aa32df4b6a85e60133a9b79d05b	refs/heads/main


HTTP 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/version


200
{"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: 200


The locked-down service still returns 200 and the startup log shows no errors:

journalctl -u gitea --no-pager -n 4


Nov 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


Gitea dashboard
Gitea repository page after a clean install — note this is an older screenshot; the 28.x UI has been refreshed but the layout is identical (screenshot: CaptainStack, Wikimedia Commons, CC BY-SA 4.0)


Gotchas I hit during this walkthrough

ProblemCause / 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 UIGitea 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.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page