Self-host Gitea with Docker, PostgreSQL and HTTPS

Run your own Git server with Gitea and PostgreSQL in Docker Compose, Git over SSH on port 2222, HTTPS from Caddy, no open sign-ups, and backups you can restore.

Tested on Ubuntu 24.04 LTS

Before you start

  • A server with Docker Engine and the Compose plugin
  • A domain or subdomain for Gitea
  • Root or a user with sudo
  • An SSH key on your computer

To self-host Gitea, run the official gitea image with a PostgreSQL container in Docker Compose, keep the web port on 127.0.0.1 behind Caddy for HTTPS, and publish Gitea's built-in SSH server on port 2222 for git push over SSH. Configure it through environment variables with sign-ups turned off, and create the admin account from the command line, so no setup page is ever open to the internet.

Gitea is a lightweight, open-source Git service (MIT license) with repositories, pull requests, issues, a package registry and Gitea Actions for CI. A small server runs it for a team.

What In this tutorial
The domain git.example.com
The server's public IPv4 address 203.0.113.10
Git over SSH port 2222
The project directory /opt/gitea
Version Gitea 28.2.0 (after 1.27, Gitea moved to whole-number major versions)

Choose how Git over SSH reaches Gitea

The server's own SSH daemon already uses port 22. Gitea's container runs its own SSH server, and there are two ways to reach it:

Option Clone URL Effort
A second port (this tutorial) ssh://git@git.example.com:2222/maya/app.git Publish one port, open it in the firewall
SSH passthrough on port 22 git@git.example.com:maya/app.git A git user on the host whose SSH logins are forwarded into the container; more moving parts, and host SSH changes

Port 2222 is simpler and keeps the host's SSH untouched. Gitea shows the right clone URL on every repository page, so users copy it rather than type it. HTTPS clones work either way.

Point the domain and open the ports

Add an A record (and AAAA, if the server has IPv6) for git.example.com pointing at the server, then open HTTP, HTTPS and Gitea's SSH port:

bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 2222/tcp

If the server sits behind a panel firewall, allow the same ports there.

Write the Compose file

bash
sudo mkdir -p /opt/gitea
cd /opt/gitea
sudo sh -c 'echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" > .env'
sudo chmod 600 .env

Create /opt/gitea/compose.yaml:

yaml
services:
  gitea:
    image: docker.gitea.com/gitea:28.2.0
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: postgres
      GITEA__database__HOST: db:5432
      GITEA__database__NAME: gitea
      GITEA__database__USER: gitea
      GITEA__database__PASSWD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      GITEA__server__DOMAIN: git.example.com
      GITEA__server__ROOT_URL: https://git.example.com/
      GITEA__server__SSH_DOMAIN: git.example.com
      GITEA__server__SSH_PORT: "2222"
      GITEA__security__INSTALL_LOCK: "true"
      GITEA__service__DISABLE_REGISTRATION: "true"
    volumes:
      - gitea_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "2222:22"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18.6-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: gitea
    volumes:
      - db_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "gitea", "-d", "gitea"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  gitea_data:
  db_data:

What the settings do:

  • GITEA__section__KEY variables are written into Gitea's app.ini at start, so the whole configuration lives in this file. Any config cheat sheet setting works this way.
  • ROOT_URL, SSH_DOMAIN and SSH_PORT are what Gitea puts in clone URLs and emails. SSH_PORT is the port users connect to (2222), while the container listens on 22.
  • INSTALL_LOCK: "true" skips the web installer. Without it, the first visitor to the site sees a setup page that can change the database and create the admin.
  • DISABLE_REGISTRATION: "true" turns off public sign-ups. You create accounts as admin. Public Gitea servers with open registration attract spam accounts quickly.
  • 127.0.0.1:3000:3000 keeps the web port off the internet (Docker's published ports bypass ufw); Caddy reaches it on localhost. Port 2222 is published on purpose.
  • The database has no published port: Gitea reaches it as db on the project's network.

Start Gitea and create the admin

bash
sudo docker compose up -d
sudo docker compose ps

After half a minute both containers are up, db as (healthy). Create your admin account with Gitea's CLI, as the git user inside the container:

bash
sudo docker compose exec -u git gitea gitea admin user create --admin --username maya --email maya@example.com --random-password

It prints generated random password is '...' and New user 'maya' has been successfully created!. Gitea asks you to change that password at first login.

Check it worked

bash
curl -s http://127.0.0.1:3000/api/v1/version

It prints {"version":"28.2.0"}.

Put Caddy in front for HTTPS

Install Caddy from its repository:

bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

Replace /etc/caddy/Caddyfile with:

text
git.example.com {
	reverse_proxy 127.0.0.1:3000
}
bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Caddy gets a Let's Encrypt certificate and has no upload size limit by default, so large pushes over HTTPS work. If you already run Caddy for other sites, add the block to the existing Caddyfile instead; Caddy as a reverse proxy for several apps shows the layout.

Check it worked

Open https://git.example.com, sign in as maya and set a new password. Then turn on two-factor login under Settings, Security.

Push over SSH

In Gitea, add your public key under Settings, SSH / GPG Keys, create a repository, and push to it from your computer:

bash
git remote add origin ssh://git@git.example.com:2222/maya/hello.git
git push -u origin main

Test the key on its own with:

bash
ssh -p 2222 git@git.example.com

Gitea answers Hi there, maya! You've successfully authenticated with the SSH key named ... and closes the connection: it doesn't give out a shell.

To skip the port in every URL, add this to ~/.ssh/config on your computer, after which git@git.example.com:maya/hello.git works too:

text
Host git.example.com
    Port 2222
    User git

Back it up

gitea dump writes the repositories, attachments, LFS objects, app.ini and a SQL dump of the database into one archive:

bash
cd /opt/gitea
sudo docker compose exec -u git -w /tmp gitea gitea dump --type tar.gz --file /tmp/gitea-dump.tar.gz
sudo docker compose cp gitea:/tmp/gitea-dump.tar.gz /root/gitea-$(date +%F).tar.gz
sudo docker compose exec -u git gitea rm /tmp/gitea-dump.tar.gz

Also take a native PostgreSQL dump, which is the easiest thing to restore into a fresh database:

bash
sudo docker compose exec -T db pg_dump -U gitea -d gitea --format=custom > /root/gitea-db-$(date +%F).dump

Keep compose.yaml and .env with them, and copy everything off the server. Gitea's backup and restore guide describes restoring a dump into a Docker install. Back up your server covers the nightly copy.

Update

Read the release notes, back up, change the tag in compose.yaml (for example 28.2.0 to the next release), then:

bash
cd /opt/gitea
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs -f gitea

Gitea migrates its database on start. Don't skip several major versions at once on a production server; step through them.

Troubleshooting

ssh: connect to host git.example.com port 2222: Connection refused or a timeout. Port 2222 isn't open in ufw or in a firewall in front of the server, or the container isn't running. Check sudo ufw status and sudo docker compose ps.

git@git.example.com: Permission denied (publickey). You connected to the server's own SSH on port 22, which doesn't know Gitea's users. Use ssh://git@git.example.com:2222/... or the ~/.ssh/config entry above. If the port is right, the key isn't added to your Gitea account.

Clone URLs show localhost:3000 or the wrong port. ROOT_URL, SSH_DOMAIN or SSH_PORT don't match. Fix them in compose.yaml and run sudo docker compose up -d (a restart keeps the old values).

Gitea logs password authentication failed for user "gitea". The database volume was created with a different password, and PostgreSQL only reads POSTGRES_PASSWORD when the volume is empty. Restore the old password in .env.

The sign-up page says "Registration is disabled". That's the setting doing its job. As admin, add users under Site Administration, User Accounts, or with gitea admin user create.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee