# 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.

Source: https://vpsnine.com/tutorials/self-host-gitea · Updated: 2026-10-21

Difficulty: intermediate · Time: about 30 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

- 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](https://vpsnine.com/help/server-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](https://docs.gitea.com/administration/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](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-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](https://docs.gitea.com/administration/backup-and-restore) describes restoring a dump into a Docker install. [Back up your server](https://vpsnine.com/help/backups-you-should-run) covers the nightly copy.

## Update

Read the [release notes](https://github.com/go-gitea/gitea/releases), 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

- Watch Gitea from outside with [Uptime Kuma](https://vpsnine.com/tutorials/self-host-uptime-kuma).
- Put several apps behind one proxy with [Docker Compose and a reverse proxy](https://vpsnine.com/tutorials/docker-compose-reverse-proxy).
- Read the official [Gitea Docker installation guide](https://docs.gitea.com/installation/install-with-docker).
