Follow any install Gitea on Ubuntu 26.04 guide today and you get a 28.x binary, because the official version.json endpoint already points there. On our test box the in-place jump from 1.25.5 ran 35 database migrations and answered on port 3000 eight seconds after systemctl start. The surprises were elsewhere: the default gitea doctor check now runs zero checks, live notifications want a WebSocket that Nginx must forward, and an upgraded server keeps self-registration open while a fresh install closes it.
This guide covers a clean native install (binary, PostgreSQL 18, systemd, Nginx and Let’s Encrypt), then a tested in-place upgrade with the backup, the migration log, the doctor run and the one rollback you cannot do. Every command and log line comes from three lab VMs, two on Ubuntu 26.04 LTS and one on 24.04, and the release we measured was Gitea 28.3.0 (tested November 2026 on Ubuntu 26.04 LTS).
What Gitea 28 changes on a native install
Gitea dropped the “1.” prefix after 1.27, so the release after 1.27.3 is 28.0.0, announced on the Gitea blog with a list of breaking changes. We put the same instance on the old and the new binary and measured the differences that a self-hosted Ubuntu server actually feels. The table compares the 1.25.5 install with the same data after the upgrade, plus a fresh 28.3.0 install where the result depends on how the server was created.
| Measured on the test box | 1.25.5 | 28.3.0 |
|---|---|---|
| Binary size (linux-amd64) | 114.3 MB | 119.9 MB |
| Resident memory, idle, 1 repo | 174 MiB | 199 MiB |
Checks run by plain gitea doctor check | at least 4 marked default | 0 |
Checks run by doctor check --all | not measured | 13, all OK |
| Self-registration after the web installer | open (DISABLE_REGISTRATION = false) | closed on a fresh install, still open on an upgraded one |
| Server Domain field in the installer | present, writes DOMAIN | removed, the domain comes from the Base URL |
| Migrations on first start after the swap | n/a | 323 to 357, about 4 s |
| Outbound migration from github.com | not measured | works with default egress settings (HTTP 201) |
The memory number rose by 25 MiB on identical data, which matters only on a 1 GB VPS. The doctor and registration rows matter everywhere, because both fail silently: the doctor prints All done (checks: 0). and looks like a pass, and the upgraded server keeps accepting sign-ups because its old app.ini spells out DISABLE_REGISTRATION = false. The breaking items from the release notes that touch a native install are these:
- Egress proxy (#39426): migrations, mirrors and webhooks now go through an internal proxy with
EGRESS_MODE = laxby default, so public hosts work and private or loopback targets need an explicit allow entry. Our GitHub migration passed untouched. The less obvious effect runs the other way: in lax mode[security] ALLOWED_HOST_LISTno longer restricts public hosts, so a server that used it as an exclusive webhook allowlist needsEGRESS_MODE = strictto keep that behaviour. - Action run retention (#38855):
[actions] RUN_RETENTION_DAYSdefaults to 400, so completed runs, logs and artifacts older than that are deleted by a nightly cron task. The 1.25.5app.inion our box had no[actions]section, so the default applied as soon as 28.3.0 started. - Git 2.25 minimum: older Git is refused at startup. Ubuntu 26.04 ships 2.53.0 and 24.04 ships 2.43.0, so neither is affected.
- WebSocket notifications: notification counts and stopwatch updates use
/-/ws. Behind a proxy without Upgrade headers the handshake gets426 Upgrade Requiredand the UI falls back to polling, so nothing breaks, but updates arrive late. The vhost in Step 7 forwards them.
If you are installing from scratch, Steps 1 to 12 already account for all four. If you run 1.25 or 1.26 today, read Step 13 before you swap anything, because a migrated database cannot be opened by the old binary again.
Prerequisites
Gitea itself is small. On our box the 28.3.0 process sat at 173 MiB resident before the installer ran and 199 MiB with one user and one repository, so the database and the repositories set the size of the server, not the forge. A 2 vCPU, 2 GB VM is the floor we tested on. Plan 4 GB and fast disk once you add Gitea Actions runners on the same host, dozens of active users or large LFS objects. The rest of the list:
- Operating system: Ubuntu 26.04 LTS server. On Ubuntu 24.04 LTS we ran Steps 2 to 6 and the installer unchanged, plus a port 80 Nginx proxy (PostgreSQL 16, Nginx 1.24, Git 2.43 and Certbot 2.9 there). The one known difference is the
http2 on;line in Step 7, which needs Nginx 1.25.1 or newer, so 24.04 readers skip it. - DNS and ports: a domain with an A record pointing at the server, with ports 80 and 443 reachable for the HTTP-01 challenge (any DNS provider works) and port 22 for Git over SSH.
- Access: a sudo user with key-based login. Work through the Ubuntu 26.04 post-install checklist and set up SSH key authentication before the server faces the internet.
Step 1: Set reusable shell variables
Three values change from reader to reader: the domain, the database password and the email Let’s Encrypt uses for expiry notices. Export them once and paste the later commands as they are:
export APP_DOMAIN="gitea.example.com"
export DB_PASS="ChangeMe_Strong_DbPass_2026"
export ADMIN_EMAIL="[email protected]"
The variables live only in the current shell, so re-export them after a reconnect. Ubuntu 26.04 replaces classic sudo with sudo-rs, which prints '-E' is ignored and drops your environment, so the commands below expand the variables in your own shell before sudo sees them rather than relying on sudo -E.
Step 2: Install PostgreSQL, Nginx, Certbot and Git
Every dependency comes from the Ubuntu archive. The postgresql meta package on 26.04 resolves to PostgreSQL 18, which Gitea supports as its primary backend:
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql postgresql-client git nginx certbot python3-certbot-nginx ufw wget
On a VM that booted a minute ago, the install can fail with Could not get lock /var/lib/dpkg/lock-frontend because unattended-upgrades is still running its first pass. Both our 26.04 and 24.04 clones hit it, and waiting a few minutes before re-running fixed it. Check what landed:
psql --version
nginx -v 2>&1
git --version
certbot --version
systemctl is-active postgresql
The 26.04 test box reported these versions, with PostgreSQL already running:
psql (PostgreSQL) 18.6 (Ubuntu 18.6-0ubuntu0.26.04.1)
nginx version: nginx/1.28.3 (Ubuntu)
git version 2.53.0
certbot 4.0.0
active
Git 2.53 clears the 2.25 floor that 28.x enforces by a wide margin. The PostgreSQL 18 on Ubuntu 26.04 guide covers tuning and replication if this database will carry more than Gitea.
Step 3: Create the git system user and directories
Gitea runs as a dedicated git account, which is also the SSH user every clone URL points at. It needs a real shell because Gitea writes forced-command entries into its authorized_keys, but it never gets a password:
sudo adduser --system --group --disabled-password --shell /bin/bash --home /home/git --gecos 'Git Version Control' git
id git
Then create the data tree and the config directory. Mode 750 keeps repositories away from other local users, and mode 770 on /etc/gitea lets the git group write app.ini while keeping everyone else out of it:
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 -p /etc/gitea
sudo chown root:git /etc/gitea
sudo chmod 770 /etc/gitea
Because both directories are closed to other users, a plain ls /etc/gitea as your login user returns permission denied. That is expected, so use sudo when you inspect them.
Step 4: Create the PostgreSQL database
Gitea wants its own role and a UTF-8 database it owns. Creating the database from template0 with an explicit C.UTF-8 locale avoids collation surprises if you later restore a dump taken on another host:
sudo -u postgres psql -c "CREATE ROLE gitea WITH LOGIN PASSWORD '${DB_PASS}';"
sudo -u postgres psql -c "CREATE DATABASE gitea WITH OWNER gitea TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8';"
sudo -u postgres psql -c "\l gitea"
Both statements print a one-word confirmation, and the listing should show gitea as the owner with UTF8 encoding:
CREATE ROLE
CREATE DATABASE
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
-------+-------+----------+-----------------+---------+---------+--------+-----------+-------------------
gitea | gitea | UTF8 | libc | C.UTF-8 | C.UTF-8 | | |
(1 row)
The same two statements worked on PostgreSQL 16 on Ubuntu 24.04 with no locale error, so there is nothing version-specific here.
Step 5: Download and verify the Gitea binary
Gitea publishes the current stable release in dl.gitea.com/gitea/version.json, and every binary has a .sha256 file next to it. Reading the version at run time keeps this step correct after the next release, and the checksum catches a truncated download before it becomes a service that will not start:
GITEA_VER=$(curl -s https://dl.gitea.com/gitea/version.json | python3 -c 'import sys,json; print(json.load(sys.stdin)["latest"]["version"])')
echo "Latest Gitea: ${GITEA_VER}"
cd /tmp
wget -q "https://dl.gitea.com/gitea/${GITEA_VER}/gitea-${GITEA_VER}-linux-amd64"
wget -q "https://dl.gitea.com/gitea/${GITEA_VER}/gitea-${GITEA_VER}-linux-amd64.sha256"
sha256sum -c "gitea-${GITEA_VER}-linux-amd64.sha256"
sudo install -m 755 "gitea-${GITEA_VER}-linux-amd64" /usr/local/bin/gitea
gitea --version
A good run prints the detected version, OK from the checksum and the version string from the installed binary:

Keep the file name gitea in /usr/local/bin. The Git hooks and the SSH forced commands Gitea writes reference that exact path, so renaming the binary breaks pushes rather than the web UI, which makes it a confusing failure to chase.
Step 6: Write the systemd unit
The upstream reference unit in contrib/service/systemd/ has no active sandboxing. Ours adds a read-only filesystem view with three writable paths, a private /tmp and no privilege escalation, and it orders Gitea after PostgreSQL so a reboot does not race the database. Create the file:
sudo vim /etc/systemd/system/gitea.service
Paste this unit, exactly as it ran on the test box:
[Unit]
Description=Gitea (Git with a cup of tea)
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
RestartSec=2s
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
# Sandboxing
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
ReadWritePaths=/var/lib/gitea /etc/gitea /home/git
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
/home/git is on the writable list because Gitea rewrites /home/git/.ssh/authorized_keys every time a user adds a key. Load the unit and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now gitea
systemctl is-active gitea
curl -sI http://localhost:3000/ | head -1
The service reports active, and the HEAD request comes back with a 405 rather than the 200 older guides promise:
active
HTTP/1.1 405 Method Not Allowed
That 405 is a healthy result. The 28.x install page accepts only GET and POST, and its response lists both in Allow headers, so the binary is up and waiting for the web installer. For reference, systemd-analyze security gitea scored this unit 8.3 on its exposure scale, which says the sandbox is a start rather than a full lockdown.
Step 7: Put Nginx and Let’s Encrypt in front of Gitea
Nginx terminates TLS on 443 and proxies to Gitea on 127.0.0.1:3000. Start with a plain port 80 vhost and let Certbot add the TLS block, because a vhost that references certificate files before they exist fails nginx -t. The three Upgrade lines are the Gitea 28 addition: without them the /-/ws notification socket gets a 426 and the UI drops back to polling. Create the vhost:
sudo vim /etc/nginx/sites-available/gitea.conf
Add the server block. GITEA_DOMAIN_HERE is replaced from your variable in the next command, and client_max_body_size lifts the 1 MB Nginx default so large pushes over HTTPS and release uploads go through:
server {
listen 80;
listen [::]:80;
server_name GITEA_DOMAIN_HERE;
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
}
}
Substitute the domain, swap the default site for this one, open the firewall and test the syntax:
sudo sed -i "s/GITEA_DOMAIN_HERE/${APP_DOMAIN}/g" /etc/nginx/sites-available/gitea.conf
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/gitea.conf /etc/nginx/sites-enabled/gitea.conf
sudo nginx -t && sudo systemctl reload nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
Allow OpenSSH before enabling UFW, otherwise the --force enable cuts the session you are typing in. The OpenSSH profile opens port 22 only, so if sshd listens elsewhere, run sudo ufw allow <port>/tcp for that port first. With port 80 open, request the certificate. Certbot answers the HTTP-01 challenge through Nginx, writes the 443 block into the same file and turns port 80 into a redirect:
sudo certbot --nginx -d "${APP_DOMAIN}" --non-interactive --agree-tos --redirect -m "${ADMIN_EMAIL}"
Our lab VMs had no public DNS name, so the test run pointed Certbot at a local Pebble ACME server with an extra --server flag and Pebble’s CA passed in REQUESTS_CA_BUNDLE. Leave that flag off and the public CA issues the certificate. Below is our output without the donation banner. A production certificate shows a 90-day expiry where the Pebble test certificate shows five years:
Account registered.
Requesting a certificate for gitea.example.com
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/gitea.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/gitea.example.com/privkey.pem
This certificate expires on 2031-11-01.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
Deploying certificate
Successfully deployed certificate for gitea.example.com to /etc/nginx/sites-enabled/gitea.conf
Congratulations! You have successfully enabled HTTPS on https://gitea.example.com
Certbot does not enable HTTP/2. Open the vhost again with sudo vim /etc/nginx/sites-available/gitea.conf and add http2 on; on its own line under the two listen ... 443 ssl lines Certbot wrote. The directive exists from Nginx 1.25.1, so it works on the 1.28 that 26.04 ships and fails nginx -t on the 1.24 in 24.04, where you leave it out. Restart and check both ports:
sudo nginx -t && sudo systemctl restart nginx
curl -s -o /dev/null -w '%{http_code} %{http_version}\n' "https://${APP_DOMAIN}/"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://${APP_DOMAIN}/"
HTTPS answers 200 over HTTP/2, and plain HTTP is redirected. Without the http2 on; line, as on 24.04, the first line reads 200 1.1 instead:
200 2
301 https://gitea.example.com/
We used a restart rather than a reload here because a curl fired straight after reload still saw HTTP/1.1 from the old workers on our box. Certbot also installs certbot.timer, so renewal needs nothing further.
No public port 80? Use a DNS-01 challenge
A server on a private LAN or behind NAT cannot answer HTTP-01. The Certbot DNS plugins prove ownership through a TXT record instead (Cloudflare, Route 53, DigitalOcean, Google Cloud DNS, Linode, OVH and RFC2136 all have one), and the Nginx and Let’s Encrypt guide for Ubuntu 26.04 walks through both challenge types. Keep the vhost from this step and combine the DNS plugin for your provider with the Nginx installer (-a dns-<provider> -i nginx), so Certbot still writes the 443 block for you. A certificate issued with certonly leaves that block for you to write by hand.
Step 8: Run the web installer
Open https://gitea.example.com/ (your domain) in a browser. The 28.x installer was rewritten (#39400): the Server Domain field is gone, the domain is taken from the Base URL, and MySQL is preselected, so three fields need attention on the first screen (the installer labels the Base URL field “Gitea Website URL”):

- Database Type: switch it from MySQL to PostgreSQL, then fill Username
gitea, theDB_PASSpassword and Database Namegitea, and leave SSL on Disable for a loopback connection. - Host:
127.0.0.1:5432, which matches the localhost-only listener PostgreSQL uses by default. - Gitea Website URL:
https://gitea.example.com/with your domain. It becomesROOT_URL, and clone URLs, email links and the domain itself are derived from it.
Further down, the first checkbox is the registration change from the 28.0.0 notes. It is ticked by default, which writes DISABLE_REGISTRATION = true:

Leave it ticked for a team server and open Administrator Account Settings to create the admin now, since with registration closed nobody can sign up to become the first user. We named ours gitadmin, and the clone URL in Step 10 uses that name. Clicking Install Gitea writes /etc/gitea/app.ini, runs the schema setup and restarts the web worker. On our box the journal logged Installation completed! at 16:23:21, then ORM engine initialization successful! and Listen: http://0.0.0.0:3000 at 16:23:28, eight seconds after the settings were saved.
The installer leaves app.ini owned by git with mode 644, and the file holds the database password in plain text. The 770 mode on /etc/gitea from Step 3 is what keeps other local users out of it, so leave that directory as it is.
Step 9: Verify the install
After login the dashboard is empty, and the footer prints the running version on every page, which is the quickest way to confirm which binary a restart picked up:

Create a repository from the plus menu and tick Initialize Repository so it gets a first commit. The new repo opens with one commit, one branch and a README:

Back in the terminal, check the API through Nginx and run the doctor. In 28.x a plain gitea doctor check selects no checks at all and prints All done (checks: 0)., which reads like a clean bill of health. Pass --all to run the 13 checks that exist:
curl -s "https://${APP_DOMAIN}/api/v1/version"
sudo -u git /usr/local/bin/gitea doctor check --all --config /etc/gitea/app.ini --work-path /var/lib/gitea
The API returns {"version":"28.3.0"}, and every doctor check should end in OK:

Finally, prove the notification socket survives the proxy. A WebSocket handshake against /-/ws through Nginx should return 101, and a 426 means the Upgrade lines from Step 7 are missing:
curl -s -o /dev/null -w '%{http_code}\n' --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" "https://${APP_DOMAIN}/-/ws"
Our box printed 101. The Site Administration entry in the user menu is the other place worth a look after install. Its Maintenance page holds the hook resync and authorized_keys rewrite tasks you reach for after a restore:

Pair the web login with the Fail2ban setup for Ubuntu 26.04 if the instance is public. The 28.3.0 installer writes no [log] section, so Gitea logs to the systemd journal and a jail for failed logins has to read the journal (backend = systemd with journalmatch = _SYSTEMD_UNIT=gitea.service) rather than a file under /var/lib/gitea/log.
Step 10: Clone and push over SSH
Gitea reuses the system OpenSSH server on port 22. When you upload a public key under Settings, SSH / GPG Keys, Gitea appends it to /home/git/.ssh/authorized_keys with a forced gitea serv key-N command, so the key can run Git operations and nothing else. Clone with the git@ URL and push a commit, replacing gitadmin/inventory with your own user and repository (a fresh client also needs git config --global user.name and user.email set before it can commit):
git clone [email protected]:gitadmin/inventory.git
cd inventory
echo "hosts: 14" > servers.txt
git add servers.txt
git commit -m "Add server list"
git push origin main
The push lands, and an interactive SSH attempt as git greets you by Gitea user name and refuses a shell:

That greeting is the test to run whenever SSH pushes start failing, because it separates a key problem (no greeting) from a repository permission problem (greeting, then a refused push).
Step 11: Register a Gitea Actions runner
Actions is enabled by default, but nothing runs until a runner registers. The runner was renamed from act_runner to gitea-runner, with its own releases on gitea.com and binaries on dl.gitea.com/gitea-runner/. Download the latest one the same way as Gitea:
RUNNER_VER=$(curl -s https://gitea.com/api/v1/repos/gitea/runner/releases/latest | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"].lstrip("v"))')
echo "Latest runner: ${RUNNER_VER}"
cd /tmp
wget -q "https://dl.gitea.com/gitea-runner/${RUNNER_VER}/gitea-runner-${RUNNER_VER}-linux-amd64"
wget -q "https://dl.gitea.com/gitea-runner/${RUNNER_VER}/gitea-runner-${RUNNER_VER}-linux-amd64.sha256"
sha256sum -c "gitea-runner-${RUNNER_VER}-linux-amd64.sha256"
sudo install -m 755 "gitea-runner-${RUNNER_VER}-linux-amd64" /usr/local/bin/gitea-runner
gitea-runner --version
The test box pulled v5.2.4 and the checksum passed. Generate a registration token from the server side, then register the runner from a working directory where it keeps its .runner state file:
RUNNER_TOKEN=$(sudo -u git /usr/local/bin/gitea actions generate-runner-token --config /etc/gitea/app.ini --work-path /var/lib/gitea | tail -1)
sudo mkdir -p /var/lib/gitea-runner
cd /var/lib/gitea-runner
sudo gitea-runner register --instance "https://${APP_DOMAIN}" --token "${RUNNER_TOKEN}" --no-interactive --name runner01 --labels "ubuntu-latest:host"
sudo chmod 600 /var/lib/gitea-runner/.runner
Registration pings the instance over HTTPS and confirms in three lines:
level=info msg="Registering runner, arch=amd64, os=linux, version=v5.2.4."
level=debug msg="Successfully pinged the Gitea instance server"
level=info msg="Runner registered successfully."
The .runner file it writes holds the runner credential and comes out world-readable, which is why the last command closes it to root. A registered runner still does nothing until gitea-runner daemon runs from that directory, and workflows stay queued until then; the runner docs ship a systemd unit for it that runs under its own user, so chown the directory and the .runner file to that user when you install it.
The ubuntu-latest:host label we registered runs jobs directly on this server as whichever user starts gitea-runner daemon, so on a shared forge any repository owner can execute commands on the host. Keep host labels for repositories you control, and give everything else Docker labels on a separate machine, as the Gitea Runner documentation describes together with its systemd unit. The Forgejo Actions CI walkthrough shows the same runner model with workflows end to end, and if you would rather keep CI outside the forge, the Woodpecker CI with Forgejo setup runs pipelines as a separate service instead.
Step 12: Back up Gitea every night
gitea dump packs repositories, app.ini, the data directory and an SQL export into one zip, but the Gitea docs recommend the native pg_dump for the database itself, so the script takes both. Create it:
sudo vim /usr/local/bin/gitea-backup.sh
Add the script. umask 077 keeps the database dump private, which our first run without it did not (the dump came out world-readable):
#!/bin/bash
set -euo pipefail
umask 077
DEST="/var/backups/gitea"
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "${DEST}"
chown git:git "${DEST}"
cd "${DEST}"
sudo -u git /usr/local/bin/gitea dump --config /etc/gitea/app.ini \
--work-path /var/lib/gitea --type zip --file "gitea-${STAMP}.zip"
sudo -u postgres pg_dump -Fc gitea > "gitea-db-${STAMP}.pgdump"
find "${DEST}" -name 'gitea-*' -mtime +14 -delete
Make it executable for root only, schedule it for 02:15 and run it once by hand:
sudo chmod 750 /usr/local/bin/gitea-backup.sh
echo "15 2 * * * root /usr/local/bin/gitea-backup.sh >> /var/log/gitea-backup.log 2>&1" | sudo tee /etc/cron.d/gitea-backup
sudo /usr/local/bin/gitea-backup.sh
sudo ls -lh /var/backups/gitea
Both files should be mode 600. On the test instance with one small repository the run took 0.42 s:
-rw------- 1 git git 54K Nov 1 16:27 gitea-20261101-162759.zip
-rw------- 1 root root 722K Nov 1 16:27 gitea-db-20261101-162759.pgdump
The upstream docs prefer a stopped instance during the dump. A nightly run on a live server trades that strictness for no downtime, and the separate pg_dump stays consistent either way. Copy /var/backups/gitea off the host, since a backup on the same disk protects against mistakes, not against losing the VM. The Ubuntu 26.04 hardening guide covers the rest of the host around it.
Step 13: Upgrade an existing Gitea server to the latest release
We tested the jump the earlier edition of this guide left readers on: Gitea 1.25.5 with PostgreSQL, one repository, two issues and an SSH key, upgraded in place to 28.3.0 without stopping at 1.26, 1.27 or 28.0. The official upgrade procedure is stop, back up, replace the binary, start, and it worked in one step on this data. The migration is one-way, so the order matters:
- Record the running version and stop the service.
- Take a
gitea dumpand apg_dumpwhile Gitea is stopped, and copy the old binary aside. - Download, verify and install the new binary under the same path.
- Start Gitea, watch the migrations in the journal, then run the doctor and check your data.
- Review the settings 28.x changed underneath an old
app.ini.
Stop the service and take both backups into their own directory, outside /var/backups/gitea, so the 14-day cleanup in the Step 12 script never deletes your rollback point. Every path is absolute because the git user cannot write to your home directory. The old binary goes next to the new one so you can tell the versions apart later, not so you can run it again:
gitea --version
sudo systemctl stop gitea
sudo install -d -o git -g git -m 700 /var/backups/gitea-upgrade
sudo -u git /usr/local/bin/gitea dump --config /etc/gitea/app.ini --work-path /var/lib/gitea --type zip --file /var/backups/gitea-upgrade/gitea-pre-upgrade.zip
sudo -u postgres pg_dump -Fc gitea | sudo tee /var/backups/gitea-upgrade/gitea-pre-upgrade.pgdump > /dev/null
sudo chmod 600 /var/backups/gitea-upgrade/gitea-pre-upgrade.pgdump
sudo cp /usr/local/bin/gitea /usr/local/bin/gitea-old
sudo ls -l /var/backups/gitea-upgrade
The first line printed gitea version 1.25.5 built with GNU Make 4.3, go1.25.8 : bindata, sqlite, sqlite_unlock_notify on our box. The dump took 0.21 s and produced a 220 KB zip with 99 files, and the custom-format pg_dump came to 350 KB. The listing at the end should show both files with mode 600 (-rw-------), the zip owned by git and the database dump by root. On a real server these scale with repository size, so time the dump on a weekday before you schedule the window.
Now replace the binary using the same version detection as Step 5, then start the service and read what it does on first boot:
GITEA_VER=$(curl -s https://dl.gitea.com/gitea/version.json | python3 -c 'import sys,json; print(json.load(sys.stdin)["latest"]["version"])')
cd /tmp
wget -q "https://dl.gitea.com/gitea/${GITEA_VER}/gitea-${GITEA_VER}-linux-amd64"
wget -q "https://dl.gitea.com/gitea/${GITEA_VER}/gitea-${GITEA_VER}-linux-amd64.sha256"
sha256sum -c "gitea-${GITEA_VER}-linux-amd64.sha256"
sudo install -m 755 "gitea-${GITEA_VER}-linux-amd64" /usr/local/bin/gitea
sudo systemctl start gitea
sudo journalctl -u gitea -o cat --since "-5min"
The journal walks through 35 numbered migrations, 323 to 357, and the instance listened on port 3000 seven seconds after the database ping. This excerpt keeps the first and last migrations and both warnings:

Neither warning needs action. The action_artifact line reports a column that PostgreSQL already holds as BIGINT, wider than the struct needs, and the bleve line means Gitea found the issue search index in an older format and rebuilds it, which costs nothing on a small instance and some disk I/O on a large one. There were no [E] lines, and app.ini came out byte-identical: 28.3.0 neither rewrote nor flagged any of the old keys.
Run the doctor with --all, as in Step 9, then check the data. On the test box all 13 checks passed, the repository, both issues and the SSH key were intact, and a fresh push over SSH went through (8e45dfa..223c9ae main -> main). Resident memory went from 174 MiB to 199 MiB.
Settings to review after the upgrade
Because 28.x keeps an old app.ini as it is, the new defaults reach an upgraded server unevenly: keys the old installer wrote keep their old values, and keys it never wrote pick up the 28.x defaults. Check these four with sudo vim /etc/gitea/app.ini, then restart Gitea:
- Self-registration: the 1.25.5 installer wrote
DISABLE_REGISTRATION = false, and after the upgrade/user/sign_upstill served a sign-up form, while the fresh 28.3.0 install answered “Registration is disabled”. Set it totrueunder[service]unless you want open sign-ups. - Action run retention: with no
[actions]section,RUN_RETENTION_DAYSis 400 and the cleanup cron deletes older runs. AddRUN_RETENTION_DAYS = 0under[actions]before the first midnight to keep run records forever. Logs and artifacts have their own limits (LOG_RETENTION_DAYS, default 365, andARTIFACT_RETENTION_DAYS, default 90, which applies only to newly uploaded artifacts), so raise those too if you need them. - Egress lists: migrations and mirrors read
[migrations] ALLOWED_HOST_LIST, webhooks and OAuth2 read[security] ALLOWED_HOST_LIST, and LAN or loopback targets need an entry such asprivatethere. If your old[security]list was meant as an exclusive allowlist, addEGRESS_MODE = strictto the same section, because lax mode now lets every public host through. The oldALLOW_LOCALNETWORKS,ALLOWED_DOMAINSandBLOCKED_DOMAINSunder[migrations]are deprecated. - Domain:
[server] DOMAINis ignored and the domain is taken fromROOT_URL, so make sureROOT_URLcarries the public HTTPS name. Ours still readhttp://gitea.example.com/from the 1.25.5 install.
Add the WebSocket lines from Step 7 to the Nginx vhost as well. An older vhost written for 1.25 passes everything except /-/ws, and the UI then polls for notifications instead of receiving them. Release-by-release detail is in the 28.0.0 announcement; the 28.3.0 maintenance release on top of it lists 21 CVE fixes, mostly around token scopes, which is reason enough not to stop at 28.0.
Rolling back means restoring, not swapping binaries
To confirm the one-way door, we put the 1.25.5 binary back over the migrated database. Gitea refused to start, and systemd kept restarting it into the same error until we reinstalled 28.3.0 and ran systemctl reset-failed gitea:

The refusal protects the data, since the database was untouched and 28.3.0 started cleanly again afterwards. A real rollback therefore means restoring the pre-upgrade pg_dump and data directory alongside the old binary, which the Gitea backup and restore page documents step by step. We did not run that restore here, so rehearse it on a copy before you rely on it. The same upgrade on a containerised install is covered in the Gitea on Kubernetes guide, and the Amazon Linux install uses the same binary layout.
Troubleshooting
Each entry below is an error we hit on the three lab VMs while building this guide, with the fix that cleared it.
“Your database (migration version: 358) is for a newer Gitea”
An older binary is running against a database a newer one has already migrated. On our box 1.25.5 expects version 323 and found 358. Install the newer binary again, run sudo systemctl reset-failed gitea and start the service. If you genuinely need the old release back, restore the backup taken before the upgrade and never edit the migration version by hand, as the log itself warns.
“HTTP/1.1 426 Upgrade Required” on /-/ws
We reproduced it on the Ubuntu 24.04 VM with a vhost that had only the Host and X-Forwarded headers: the handshake through Nginx returned 426 while the same request straight to port 3000 on the 26.04 box returned 101. Pages still load and notification counts fall back to polling, so the symptom is late updates rather than an error page. Add proxy_http_version 1.1 and the Upgrade and Connection headers from Step 7, then reload Nginx and repeat the curl test from Step 9.
“All done (checks: 0).” from gitea doctor check
Nothing is wrong and nothing was checked. In 28.3.0 gitea doctor check --list marks no checks as default, where 1.25.5 marked paths, check-db-version, check-user-type and authorized-keys as default, and those four checks are gone from the 28.3.0 list entirely. Add --all, or --run followed by check names from --list, and keep the --config and --work-path flags so the doctor reads the same app.ini as the service.
“HTTP/1.1 405 Method Not Allowed” from curl -I on port 3000
Before the web installer runs, Gitea serves only the install page, and that page accepts GET and POST but not the HEAD request curl -I sends. Treat the 405 as proof the service is up, or use curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/, which sends a GET.
“sudo: preserving the entire environment is not supported, ‘-E’ is ignored”
Ubuntu 26.04 ships sudo-rs as sudo, and it does not implement -E. Any command that relied on sudo -E to pass a variable such as REQUESTS_CA_BUNDLE runs without it. Pass the variable explicitly with sudo env NAME=value command, or let your own shell expand ${APP_DOMAIN} and friends before sudo runs, as every command in this guide does.