Gitea 28.0.0 shipped on 30 September 2026 and it is the release that follows 1.27: the project dropped the "1." from its version numbers. On Docker the upgrade comes down to changing the image tag, but 28 changes how migrations, mirrors and webhooks leave Gitea, and a 1.27 configuration can stop it from starting. Here I upgrade a real instance with PostgreSQL, mirrors, webhooks and Actions runs, and cover what broke, how to set EGRESS_MODE and how to go back to 1.27. The same guide is available in Spanish.

Key takeaways

  • Gitea 28.0.0 is the successor of 1.27.3: the official announcement explains that the "1." prefix is gone, which is why it is not called 1.28.0.
  • 28.1.0 came out on 6 October with 31 fixes and no breaking changes. I tested 28.0.0; if you upgrade now, use the latest 28.x.
  • Migrations, mirrors and webhooks go through an internal proxy with a new mode, EGRESS_MODE (lax by default, or strict). In lax mode, [security] ALLOWED_HOST_LIST no longer blocks public hosts.
  • A wildcard entry in [migrations] BLOCKED_DOMAINS such as 192.168.1.*, valid on 1.27.3, stops 28 from starting: in my test the container restarted 8 times in 25 s.
  • RUN_RETENTION_DAYS defaults to 400 and the cleanup_action_runs task deletes older Actions runs together with their jobs and logs. Setting it to 0 keeps them all.
  • The database goes from migration 343 to 356 and 1.27.3 refuses to start on it: going back means restoring the backup.

What changes in Gitea 28, and why is it not called 1.28?

Gitea 28 is the next major release after 1.27, under a new number. The Gitea blog announcement puts it this way: "Gitea drops the historical 1. prefix from its version numbers, so this release is 28.0.0 rather than 1.28.0." The previous branch ended at 1.27.3, released on 29 August 2026, and 28.0.0 came out on 30 September.

The release notes flag two changes as breaking: the internal proxy for Git network operations with its new egress rules (PR 39426), and the expiry of Actions runs (PR 38855). The blog post adds three more worth reading before you upgrade:

  • Git 2.25 or newer: Gitea will not start with an older version. The 28.0.0 Docker image ships Git 2.54.0, so this does not affect Docker.
  • Self-registration and DOMAIN: sign-up is off unless you set DISABLE_REGISTRATION = false, and Gitea stops reading [server] DOMAIN and takes the domain from ROOT_URL.
  • Stricter Actions workflows: a job-level if: is evaluated before the matrix expands, and fail-fast is now enforced. I did not test this.

If you do not have Gitea yet, start with the guide to install Gitea with Docker; this one starts from an install like that. If you are torn between Gitea and its fork, there is also the guide to Forgejo with Docker.

How I tested the upgrade

I ran Gitea 1.27.3 with PostgreSQL 17.11 on Docker Compose, on an 18-core arm64 Linux machine, using the official images from docker.gitea.com. Before upgrading I created two users, a repository with an issue and an Actions workflow with three runs served by Gitea Runner 4.0.0. I added two mirrors (one from GitHub, one from Docker’s internal network) and two webhooks, one to example.com and one to an nginx on the same network.

The egress configuration was the typical one of a 1.27 tweaked over time. It had an allowed-host list for webhooks, the same list repeated in the old [webhook] section, and the usual migration keys. This is how it looked in app.ini:

[security]
ALLOWED_HOST_LIST = private

[webhook]
ALLOWED_HOST_LIST = private

[migrations]
ALLOW_LOCALNETWORKS = true
ALLOWED_DOMAINS = github.com,gitea

With this configuration, 1.27.3 rejected the webhook to example.com with webhook can only call allowed HTTP servers (check your security.ALLOWED_HOST_LIST setting) and rejected a migration from Codeberg with You can not import from disallowed hosts., because Codeberg was not in ALLOWED_DOMAINS. That is the behaviour 28 changes.

Step 1: back up with Gitea stopped

Gitea’s backup documentation asks you to stop the instance before copying, because the database and the repositories change together and a half-taken copy is inconsistent. It also recommends the database’s native tools over the SQL dump from gitea dump. Pin the version in a .env file so the tag change is a single line:

services:
  gitea:
    image: docker.gitea.com/gitea:${GITEA_TAG}
    # the rest of the service stays the same

The .env holds GITEA_TAG=1.27.3. With that in place, stop Gitea (leave PostgreSQL running) and dump the database and the /data volume:

docker compose stop gitea
docker exec gitea-db pg_dump -U gitea -d gitea -Fc \
  -f /tmp/gitea-1.27.3.dump
docker cp gitea-db:/tmp/gitea-1.27.3.dump ./backup/
docker run --rm -v gitea_gitea_data:/data:ro \
  -v "$PWD/backup":/backup alpine:3.22 \
  tar czf /backup/gitea-data-1.27.3.tar.gz -C /data .

pg_dump -Fc writes a custom-format dump that pg_restore reads back later, and the tar copies repositories, app.ini, SSH keys and Actions logs. The name gitea_gitea_data is the Compose project name plus the volume; check yours with docker volume ls. In my lab the dump took 372 KB and the volume archive 21 MB. If you already take encrypted backups with restic, add both files to the repository before you continue.

Step 2: remove the keys that 28 deprecates from app.ini

Removing a GITEA__ variable from docker-compose.yml does not remove it from app.ini. The image’s startup script, environment-to-ini, writes every variable into the file and never deletes anything, so the old keys stay even after you change the Compose file. I checked: after changing the variables, 28 still logged this at startup:

[E] Deprecation: config option `[migrations].ALLOWED_DOMAINS`
    present, please use `[migrations].ALLOWED_HOST_LIST` instead
    because this fallback will be/has been removed in v28.0.0

The same warning appears for [migrations].ALLOW_LOCALNETWORKS, [migrations].BLOCKED_DOMAINS and [webhook].ALLOWED_HOST_LIST. Delete them with Gitea stopped, from a helper container that mounts the volume:

docker run --rm -v gitea_gitea_data:/data alpine:3.22 sed -i \
  -e '/^ALLOW_LOCALNETWORKS/d' \
  -e '/^ALLOWED_DOMAINS/d' \
  -e '/^BLOCKED_DOMAINS/d' \
  -e '/^\[webhook\]/,/^\[/{/^ALLOWED_HOST_LIST/d}' \
  /data/gitea/conf/app.ini

Check the result with grep -n HOST_LIST on the same file before starting. If any of those lists had wildcards, read the next section before translating it to the new syntax.

Why does Gitea 28 fail to start after the upgrade?

Gitea 28 refuses to start when a migration block list has an entry it cannot parse. 1.27 said of this list "Wildcard is supported" and accepted BLOCKED_DOMAINS = 192.168.1.*,gitlab.* without complaint; I checked by starting 1.27.3 with that value. 28 maps the old key to BLOCKED_HOST_LIST, no longer accepts wildcards in IP addresses or example.*, and stops with these two lines:

[E] [migrations] BLOCKED_HOST_LIST ignores an invalid entry:
    target "192.168.1.*" is not an IP, CIDR, named range,
    or valid hostname pattern
[F] [migrations] BLOCKED_HOST_LIST has invalid entries,
    fix them so no blocked host is allowed

The full fatal line, exactly as Gitea writes it, is modules/setting/security.go:53:checkHostList() [F] [migrations] BLOCKED_HOST_LIST has invalid entries, fix them so no blocked host is allowed. With restart: unless-stopped the container went into a loop: 8 restarts in 25 s. The exit code was 0, so restart: on-failure would not restart it and a monitor that watches the exit code would not alert either. The fix is to write the entry as a CIDR (192.168.1.0/24) and the domains in curl syntax: example.com covers the domain and its subdomains, and *.example.com covers subdomains only.

In ALLOWED_HOST_LIST the error is not fatal, but it is worse: the entry is ignored with an [E] and Gitea starts. I tried * and external, the two 1.27 ways to allow everything:

  • *`**:catch-all host pattern "*" matches every host and is not allowed`
  • external: the "external" builtin was replaced by EGRESS_MODE = lax

What does EGRESS_MODE do, and what changes with ALLOWED_HOST_LIST?

EGRESS_MODE decides whether the allowed-host list is exclusive or only guards the internal network. In lax, the default, any public host is allowed and the list is only consulted for private, loopback and CGNAT addresses. In strict, every target has to be on the list. There are two keys, one per section: [security] EGRESS_MODE governs webhooks and OAuth2, and [migrations] EGRESS_MODE governs migrations and mirrors.

With the same 1.27 configuration, 28 started with a warning ([security] ALLOWED_HOST_LIST only restricts private hosts in the default lax mode) and the webhook to example.com, which used to be blocked, reached its target and got a 405. This table sums up the deliveries of both webhooks for each combination I tested ("Delivered" means the request reached its target, even though the LAN nginx answered 404):

Version and configuration Webhook to example.com Webhook to the LAN
1.27.3, ALLOWED_HOST_LIST = private Blocked Delivered
1.27.3, ALLOWED_HOST_LIST = * Delivered Delivered
28.0.0 lax, ALLOWED_HOST_LIST = private Delivered Delivered
28.0.0 strict, ALLOWED_HOST_LIST = private Blocked Delivered
28.0.0 lax, ALLOWED_HOST_LIST = * Delivered Blocked

The last row is the trap for anyone who had * on 1.27 and notifies a Jenkins or a Woodpecker on the LAN: the entry is ignored and the delivery fails with denied by egress policy: b7p3-hook(172.24.0.5) needs an explicit allow entry (private/loopback/CGNAT). If you want to keep the list as an exclusive allowlist, which is what 1.27 did with private, set strict.

strict has a second trap: an entry without a port only allows ports 80 and 443. With [migrations] ALLOWED_HOST_LIST = github.com, private, the mirror of http://gitea:3000/... stopped syncing and logged remote URL is not allowed: migration/cloning from 'gitea' is not allowed. With private:3000 it worked again. These are the variables I left the instance with:

    environment:
      GITEA__security__EGRESS_MODE: "strict"
      GITEA__security__ALLOWED_HOST_LIST: "private"
      GITEA__migrations__EGRESS_MODE: "strict"
      GITEA__migrations__ALLOWED_HOST_LIST: "github.com, private:3000"
      GITEA__actions__RUN_RETENTION_DAYS: "0"
      GITEA__audit__RECORD_OUTPUT: "database"

Migrations change in lax too. With [migrations] ALLOWED_HOST_LIST = github.com, private, a migration from Codeberg reached the server (it failed with "repository not found" because the repository did not exist), while on 1.27, with ALLOWED_DOMAINS, it was rejected before leaving. The metadata address 169.254.169.254 stayed blocked in lax, and the 28.0.0 app.example.ini states that reserved addresses (link-local, cloud metadata) are always denied. 28.1.0 changes this: link-local, private networks, CGNAT and documentation ranges become "restricted". They stay blocked by default in lax, but an explicit CIDR in ALLOWED_HOST_LIST allows them (PR 39560). Only ranges such as 6to4, Teredo, multicast and broadcast stay reserved with no exception. Do not put 169.254.0.0/16 in the list unless you really need it.

Step 3: change the tag and start Gitea 28.0.0

With the backup taken and app.ini clean, the version change is one line in .env and an up -d. Gitea applies the schema migrations at startup:

sed -i 's/^GITEA_TAG=.*/GITEA_TAG=28.0.0/' .env
docker compose up -d gitea
docker compose logs gitea | grep -E 'Migration\[|\[E\]|\[W\]|\[F\]'

In my test the log showed 13 migrations, from Migration[343]: Add max_parallel column to action_run_job to Migration[355]: Add AutoMerge merged_commit_id column, including the audit event table and the deploy token columns. After a clean start both mirrors synced, runner 4.0.0 picked up a new run without registering again, and the issue and repositories were all in place.

Does Gitea 28 delete old Actions runs?

Yes. 28 adds [actions] RUN_RETENTION_DAYS with a default of 400 days, and the scheduled cleanup_action_runs task deletes older finished runs every midnight, together with their jobs, logs and artifacts. The announcement sums it up in one sentence: "Completed Actions runs are now deleted after 400 days by default". To test it, I moved the date of two of the three runs back to June 2025 and ran the task by hand through the admin API:

curl -X POST -H "Authorization: token your_admin_token" \
  http://localhost:3000/api/v1/admin/cron/cleanup_action_runs

The log said Deleted 2 old Actions runs before 2025-08-26T09:03:11Z, that is, 400 days before the run. Jobs went from 4 to 2 and tasks from 3 to 1. If your CI history doubles as an audit trail, set RUN_RETENTION_DAYS = 0 before starting 28: with that value I repeated the test on the restored copy and the task deleted nothing. Since 28, 0 also means "forever" for LOG_RETENTION_DAYS and ARTIFACT_RETENTION_DAYS.

Is self-registration switched off on Docker?

Not in the Docker image. The announcement says sign-up is off unless you set DISABLE_REGISTRATION = false on purpose, but the image’s startup script generates app.ini with DISABLE_REGISTRATION=${DISABLE_REGISTRATION:-"false"}. So the file of a Docker install already has false written in it, and so does a fresh 28.0.0 container. In both cases /user/sign_up showed the registration form.

If your instance faces the internet, set GITEA__service__DISABLE_REGISTRATION: "true" in the Compose file, as the install guide does. As for DOMAIN, in my lab the SSH clone URL did not change because the image also writes SSH_DOMAIN into app.ini. Still, check that ROOT_URL holds your public domain.

Which Gitea 28 features I checked

From the feature list I tested the three that change day-to-day administration the most: the audit log, HTTPS deploy tokens and bot accounts. Everything else (user impersonation, the Actions queue, diff filters) is described in the announcement and I did not test it.

  • Audit log: off by default, enabled with [audit] RECORD_OUTPUT = database, with 30 days of retention by default. It recorded the startup (System started [Gitea 28.0.0]) and two visibility changes made with a token, with user, token, IP and origin. Creating a deploy token through the API and a bot through the CLI did not show up.
  • Deploy tokens: POST /api/v1/repos/{owner}/{repo}/keys/tokens returns a token with a gdt_ prefix that acts as the password for Git over HTTPS. A read-only one cloned its private repository, got "not found" on another private repository, and its push was rejected with pre-receive hook declined.
  • Bot accounts: gitea admin user create --user-type Bot creates the account without a password. If you try to set one, the CLI answers a bot account cannot have a password or authentication source, and the API returns it with "type": "Bot".

Audit Log page of a repository in Gitea 28.0.0 showing a repository:visibility:update event recorded for labadmin through an access token from the API.

One more change you will notice behind a reverse proxy: live notifications move from server-sent events at /user/events to a WebSocket at /-/ws. If the proxy does not forward the Upgrade header, the counters fall back to polling.

How to go back to Gitea 1.27 if something goes wrong

Going back to 1.27 means restoring the backup, because 1.27.3 will not start on a migrated database. I tried changing only the tag and 1.27.3 stopped with Migration Error: Your database (migration version: 356) is for a newer Gitea, you can not use the newer database for this old Gitea release (343). The full rollback, with PostgreSQL running and Gitea stopped, is this:

docker compose stop gitea
docker exec gitea-db psql -U gitea -d postgres \
  -c "DROP DATABASE gitea;" -c "CREATE DATABASE gitea OWNER gitea;"
docker cp backup/gitea-1.27.3.dump gitea-db:/tmp/restore.dump
docker exec gitea-db pg_restore -U gitea -d gitea /tmp/restore.dump
docker run --rm -v gitea_gitea_data:/data \
  -v "$PWD/backup":/backup:ro alpine:3.22 sh -c \
  'rm -rf /data/* && tar xzf /backup/gitea-data-1.27.3.tar.gz -C /data'
sed -i 's/^GITEA_TAG=.*/GITEA_TAG=1.27.3/' .env
docker compose up -d gitea

Also put docker-compose.yml back to the 1.27 variables before the last command, because environment-to-ini would write the new keys into the restored app.ini again. After the restore, 1.27.3 started at schema version 343, the two runs deleted by retention were back and the mirrors were still there. Anything you did on 28 between the backup and the rollback is lost. If you prefer volume backups with another tool, the Docker volume backup lab walks through the same pattern step by step.

Frequently asked questions

Can I jump from Gitea 1.26 straight to 28?

The official upgrade guide says Gitea checks the schema on every startup and applies any missing migrations, but it says nothing about skipping releases, and I only tested the jump from 1.27.3. The careful path is to move to 1.27.3 first, with its own backup, and read the breaking changes of every release in between.

Do I need to upgrade Gitea Runner for 28?

Not in my test. Gitea Runner 4.0.0, released on 24 September 2026, worked against both 1.27.3 and 28.0.0 without registering again, and its announcement says it stays compatible with existing Gitea versions.

What should I set in EGRESS_MODE if I do not use host lists?

Nothing: the default lax allows public hosts and blocks the internal network and reserved addresses. You only need an entry in ALLOWED_HOST_LIST when a webhook or a mirror points at a private IP, for example private or a specific CIDR with its port.

Conclusion

Upgrading to Gitea 28 on Docker means changing a tag, but first you have to review app.ini, not just the Compose file. Three things cost me time in the test: the restart loop caused by a wildcard in BLOCKED_DOMAINS, webhooks going out to the internet again in lax mode, and the 400 days of Actions retention. Back up with Gitea stopped, clean out the legacy keys, choose between lax and strict for each section and set RUN_RETENTION_DAYS before you start. If something fails, rolling back to 1.27 with pg_restore and the volume archive worked in my lab without losing anything from before the backup.

Sources: [1] Gitea, release v28.0.0 on GitHub[1], [2] Gitea blog, Gitea 28.0.0 announcement[2], [3] Gitea, PR 39426 on the internal Git proxy[3], [4] Gitea, PR 38855 on RUN_RETENTION_DAYS[4], [5] Gitea, app.example.ini for 28.0.0[5], [6] Gitea, app.example.ini for 1.27.3[6], [7] Gitea docs, configuration cheat sheet[7], [8] Gitea docs, upgrade from an old Gitea[8], [9] Gitea docs, backup and restore[9], [10] Gitea blog, Gitea Runner 4.0.0 announcement[10], [11] Gitea, release v28.1.0 on GitHub[11], [12] Gitea, PR 39560 on restricted and reserved ranges[12].

Sources

  1. Gitea, release v28.0.0 on GitHub
  2. Gitea blog, Gitea 28.0.0 announcement
  3. Gitea, PR 39426 on the internal Git proxy
  4. Gitea, PR 38855 on RUN_RETENTION_DAYS
  5. Gitea, app.example.ini for 28.0.0
  6. Gitea, app.example.ini for 1.27.3
  7. Gitea docs, configuration cheat sheet
  8. Gitea docs, upgrade from an old Gitea
  9. Gitea docs, backup and restore
  10. Gitea blog, Gitea Runner 4.0.0 announcement
  11. Gitea, release v28.1.0 on GitHub
  12. Gitea, PR 39560 on restricted and reserved ranges