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.

Database startup was initially misleading

Gitea reported:

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

MariaDB later logged that it was ready for connections, while this command returned Access denied:

docker exec docker-mariadb-1 \
  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.

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://code.minhdb.io/

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: code.minhdb.io
  GITEA__server__PROTOCOL: http
  GITEA__server__ROOT_URL: https://code.minhdb.io/

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.

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.

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.