Why this was more than an image update

My Gitea installation had been running version 1.18.0 for several years. Upgrading a stateful service is not equivalent to replacing a stateless web container: Gitea owns database migrations, configuration, repositories, keys, and user-generated data.

Before changing the image, I backed up MariaDB and the persistent Gitea directories. I also recorded the current Compose configuration and container versions. That created both a recovery path and a baseline for post-upgrade comparison.

The service was eventually moved to a pinned Gitea 1.26.2 image:

services:
  server:
    image: gitea/gitea:1.26.2

Pinning the version makes future changes intentional. A floating latest tag can turn an ordinary container recreation into an unplanned application upgrade.

The endpoints above describe my completed migration; they are not a recommendation to copy a direct 1.18-to-1.26 jump. For any old installation, the upgrade runbook should first cover the release notes for every intervening minor version, required stepping-stone versions, and database compatibility. It should not proceed without:

  • A native database dump.
  • A backup of /data, including app.ini, repositories, attachments, and keys.
  • The exact old image reference or digest.
  • Enough free disk space for migration work.
  • A written rollback procedure that I have tested on restored data.

Commands such as these inventory service names and images without printing the fully interpolated Compose environment, which may contain passwords:

docker compose config --services
docker compose config --images
docker compose ps
docker inspect gitea --format '{{.Config.Image}}'

I avoid publishing or pasting unfiltered docker compose config and docker inspect output because both can expose credentials.

Database startup was initially misleading

Gitea reported:

dial tcp ...:3306: connect: connection refused

MariaDB later logged that it was ready for connections, while an unauthenticated health probe returned Access denied:

docker compose exec mariadb \
  mariadb-admin ping -h 127.0.0.1

Those messages described different stages:

  • connection refused meant nothing was accepting TCP connections yet.
  • Access denied meant MariaDB was accepting connections but rejected an unauthenticated root login.

That distinction is diagnostically useful, but an access-denied response is not a complete database health check. The real check uses a dedicated health-check account or the container's configured credential without printing it in logs or shell history.

Docker DNS confirmed that the stable address was the Compose service name:

docker exec gitea getent hosts mariadb

Gitea should connect to mariadb:3306, not a container IP that may change after recreation:

environment:
  GITEA__database__HOST: mariadb:3306

Correcting the external URL

After the upgrade, Gitea warned that it detected:

http://git.example.com/

That did not match the public HTTPS site. The application receives HTTP from Nginx internally, but users access HTTPS externally. Both sides need to describe that relationship accurately.

The Gitea configuration became:

environment:
  GITEA__server__DOMAIN: git.example.com
  GITEA__server__PROTOCOL: http
  GITEA__server__ROOT_URL: https://git.example.com/

PROTOCOL describes Gitea's internal listener. ROOT_URL is the canonical address used in links, emails, webhooks, OAuth callbacks, and clone URLs.

The Nginx proxy passes the original request information:

location / {
    proxy_pass http://127.0.0.1:8080;

    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;
}

This lets Gitea know that the original client used HTTPS even though the final proxy hop uses HTTP.

The full commented Nginx virtual host is maintained in A self-host life. Repeating it here would obscure this article's narrower lesson: the internal listener can use HTTP while the canonical public URL and forwarded scheme remain HTTPS.

Cleaning deprecated configuration

Startup logs also identified an obsolete logging option:

[log].ROUTER ... use [log].logger.router.MODE

The active configuration now contains:

[log]
logger.router.MODE = console

I verified the file from both the host and container, checked for environment overrides, restarted Gitea, and searched only the new startup logs:

docker compose logs --since 1m server | grep -i deprecat

No output confirmed that the warning belonged to the previous process and had been resolved.

I also checked the running version and new startup boundary rather than assuming that a successful container start meant the migration was complete:

docker compose exec server gitea --version
docker compose logs --since 5m server

Validation checklist

After the upgrade, I verified:

  • MariaDB migrations completed.
  • Gitea reached its web listener.
  • Existing users and repositories remained available.
  • HTTPS links used the correct host and scheme.
  • SSH clone URLs used the intended external port.
  • Registration remained disabled.
  • Startup logs contained no unresolved migration or deprecation errors.
  • Nginx configuration passed nginx -t.

The main lesson was to treat application, database, and proxy configuration as one system. An upgrade can succeed at the container level while still producing incorrect public URLs or unstable database connections.

The official Gitea Docker installation guide, backup and restore guide, and release notes remain the authority for commands that vary by version.