Skip to content

How to Deploy Gitea on a VPS

Updated Sep 2026

We earn commissions when you shop through the links below. Full disclosure →

Self-host Gitea on your own VPS — a lightweight Go Git service with pull requests, issues, and built-in Actions CI, with HTTPS, SSH clones, and real backups.

Before you start
  • A VPS with 2 GB RAM (Gitea itself runs in well under 512 MB — the headroom is for Postgres, a proxy, and large clones)
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain you can point at the server — HTTPS matters for Git credentials
  • Docker Engine + Compose installed (see the base guide below)
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What Gitea is

Gitea is a self-hosted Git service: repositories, pull requests, issues, releases, organizations, a package registry, and built-in Actions CI (with a runner you install separately) — the GitHub workflow you already know, on a box you own. It's a single Go binary under the MIT licence, rated 2 / 5 to deploy, and it starts happily on 512 MB of RAM. That combination is the whole appeal: a full forge that costs a few dollars a month to run and doesn't need a dedicated ops person to keep alive.

Two things are worth deciding before you type anything. First, which database — Gitea ships with SQLite, which is genuinely fine for a personal instance or a handful of collaborators, and speaks Postgres when a team starts opening pull requests concurrently. Second, how Git-over-SSH reaches the container, because that's the one part of a Dockerized forge that isn't obvious. Both get their own section below.

Server sizing — the repositories decide, not Gitea

Gitea's own footprint is small and stays small. The web service idles in the low hundreds of megabytes; the memory graph only moves when something does work — a large clone, a repository indexing pass, or a CI job.

Sizing by use:

  • 1 GB RAM / 1 vCPU — a personal forge with SQLite, dozens of repositories, one or two people. Comfortable.
  • 2 GB RAM / 2 vCPU — a small team on Postgres, with code search enabled and concurrent pushes. This is the sensible default.
  • 4 GB RAM+ — you're running Gitea Actions runners on the same box. CI is the expensive tenant, not Gitea; size for the builds, not the forge.

Disk is the axis people under-plan. Repositories, LFS objects, the package registry, and CI artifacts all land on the same volume, and none of them shrink on their own. Start at 40 GB and pick a provider where you can grow the disk later — most providers let you attach a volume or resize the disk without a rebuild.

Prepare the server

This guide assumes Docker Engine and the Compose plugin are installed, along with a non-root deploy user and a ufw firewall. If that's not done yet, work through Docker & Compose on Ubuntu first — it's the base layer for every app on this site.

With that in place, open the ports Gitea actually needs. HTTP and HTTPS are for the reverse proxy; 222 is the host port that will carry Git-over-SSH into the container (the next section explains why it isn't 22):

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw allow 222/tcp
sudo ufw enable
sudo ufw status verbose

Create the project directory as deploy:

mkdir ~/gitea && cd ~/gitea
Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Install Gitea

Gitea's Docker image reads configuration from GITEA__section__KEY environment variables and writes everything else to /data. Start with the SQLite build — one container, no database to operate:

# docker-compose.yml
services:
  gitea:
    image: gitea/gitea:1
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      # Public URL Gitea prints in clone commands and emails.
      GITEA__server__ROOT_URL: "https://git.example.com/"
      GITEA__server__DOMAIN: "git.example.com"
      GITEA__server__SSH_DOMAIN: "git.example.com"
      # Port shown in ssh:// clone URLs …
      GITEA__server__SSH_PORT: "222"
      # … and the port the built-in SSH server listens on inside the container.
      GITEA__server__SSH_LISTEN_PORT: "22"
    volumes:
      - ./gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      # Web UI on loopback only — the reverse proxy is the sole public door.
      - "127.0.0.1:3000:3000"
      # Git-over-SSH: host 222 → container 22.
      - "222:22"

Bring it up:

docker compose up -d
docker compose logs -f

When to move to Postgres. SQLite holds up well for one person and small teams, but it serializes writes — with several people pushing, merging, and running CI at once you'll feel it. Switching is a database choice made before first run, so decide now rather than migrating later. Add a db service and point Gitea at it:

# docker-compose.yml — Postgres variant
services:
  gitea:
    image: gitea/gitea:1
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__server__ROOT_URL: "https://git.example.com/"
      GITEA__server__DOMAIN: "git.example.com"
      GITEA__server__SSH_DOMAIN: "git.example.com"
      GITEA__server__SSH_PORT: "222"
      GITEA__server__SSH_LISTEN_PORT: "22"
      GITEA__database__DB_TYPE: "postgres"
      GITEA__database__HOST: "db:5432"
      GITEA__database__NAME: "gitea"
      GITEA__database__USER: "gitea"
      GITEA__database__PASSWD: "CHANGE_ME_LONG_RANDOM"
    volumes:
      - ./gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "222:22"
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: CHANGE_ME_LONG_RANDOM
      POSTGRES_DB: gitea
    volumes:
      - ./postgres:/var/lib/postgresql/data

Generate that password rather than inventing one:

openssl rand -base64 36 | tr -d '\n'

Git over SSH — pick one of two approaches

HTTPS clones work the moment the reverse proxy is up. SSH clones need a decision, because the container has its own SSH server and the host already has one listening on port 22.

Approach 1 — publish the container's SSH on a different host port. This is the compose file above: host 222 maps to container 22, and the two SSH_PORT / SSH_LISTEN_PORT settings make Gitea advertise the right thing. Clone URLs come out as ssh://git@git.example.com:222/user/repo.git, users add their public key in the web UI, and nothing on the host changes. Take this one unless you have a specific reason not to — it is one line of compose and has no moving parts.

The only cost is the non-standard port, which some corporate firewalls block outbound. Users who don't want to type it can put it in ~/.ssh/config:

Host git.example.com
  Port 222
  User git

Approach 2 — host SSH passthrough. If clones must be on port 22, keep the host's sshd in front and have it hand Git commands to the container. That means a git user on the host, an AuthorizedKeysCommand in sshd_config that calls gitea keys inside the container to look keys up, and a shell wrapper that forwards SSH_ORIGINAL_COMMAND in via docker exec. It works, and it's how you get plain git@git.example.com:user/repo.git URLs — but it touches the host's SSH daemon, which is the one service you really don't want to misconfigure on a remote box. Follow the "SSH Container Passthrough" section of the Gitea rootless Docker installation docs step by step — that's where the passthrough walkthrough actually lives, not the standard install page — and keep a second SSH session open while you edit sshd_config.

HTTPS + domain

Gitea handles credentials, tokens, and private source — serve it over TLS only. Create an A record for git.example.com pointing at the server's public IP and wait for it to resolve before requesting a certificate.

Then put a reverse proxy in front of 127.0.0.1:3000. The simplest path is Automatic HTTPS with Caddy; the Caddyfile entry is one block:

git.example.com {
    reverse_proxy 127.0.0.1:3000
}

If you run Caddy as a container rather than on the host, 127.0.0.1 is the proxy's loopback, not the host's. Put Caddy and Gitea in the same compose file and proxy to the service name instead — reverse_proxy gitea:3000 — dropping the 127.0.0.1:3000 publish.

One Gitea-specific gotcha: large pushes need a generous body limit. Caddy streams request bodies without a size cap by default, so it's fine as-is; on nginx you must raise client_max_body_size (say 512m) or pushes of a big repository fail with an opaque HTTP 413.

First login and hardening

Load https://git.example.com. On first run Gitea shows an installation page — confirm the database settings, set the Server Domain and Gitea Base URL to your real hostname, and finish. The settings are written to ./gitea/gitea/conf/app.ini, which becomes the file of record from then on.

The first registered account becomes the administrator. Register yourself immediately, before anything else. Then close the door:

  1. Disable open registration. In app.ini, under [service], set DISABLE_REGISTRATION = true (or add GITEA__service__DISABLE_REGISTRATION: "true" to the compose environment and recreate). New users are then created by an admin, or invited. An internet-facing forge with open signups collects spam accounts within days.
  2. Turn on two-factor auth for your own account under Settings → Security, and require it for admins.
  3. Decide whether anonymous browsing is allowed. REQUIRE_SIGNIN_VIEW = true under [service] makes the whole instance private — the right setting for a company forge, the wrong one if you publish open source.
  4. Restrict who can create organizations and repositories if more than a couple of people will have accounts.

Apply config changes with a restart:

docker compose restart gitea

Backups

A Gitea backup is three things, and missing any one of them makes the restore partial:

  • The repositories — bare Git repos under ./gitea/git/repositories.
  • The database — ./gitea/gitea/gitea.db for SQLite, or a pg_dump for Postgres. This holds users, issues, pull requests, and permissions. Repos without it are just code with no history of the conversation around it.
  • The custom directory — ./gitea/gitea, which carries conf/app.ini, the attachments, avatars, LFS objects, and the SSH host keys.

Gitea's own dump command captures all of that in one consistent archive, which is far safer than copying a live SQLite file:

docker compose exec -u git -w /tmp gitea \
  gitea dump -c /data/gitea/conf/app.ini -f /tmp/gitea-backup.zip

Then move the archive off the box and clean up:

docker compose cp gitea:/tmp/gitea-backup.zip ./gitea-$(date +%F).zip
docker compose exec -u git gitea rm /tmp/gitea-backup.zip

Ship that file somewhere the VPS dying doesn't take with it — object storage, another machine, your laptop. Run it on a schedule, and restore it once into a throwaway instance so you find out now, not during an outage, that the dump is complete. If you're on Postgres, add a pg_dump alongside it:

docker compose exec db pg_dump -U gitea gitea | gzip > gitea-db-$(date +%F).sql.gz

Upgrades

Gitea's :1 tag tracks the current major line, so an upgrade is a deliberate pull:

cd ~/gitea
docker compose pull
docker compose up -d

Gitea runs its database migrations automatically at startup, and they are one-way — take a dump first (previous section) so a rollback is possible. Skim the release notes before a minor-version jump; the project is stable but occasionally changes a default. If you'd rather control the exact version, pin the full version tag instead of :1 and bump it on your own schedule.

Troubleshooting

Clone URLs show the wrong host or port. Gitea generates them from ROOT_URL, SSH_DOMAIN, and SSH_PORT. If you set up behind a proxy without setting ROOT_URL, it guesses from the request and gets it wrong. Fix the values in the compose environment: block and recreate the container — the GITEA__ variables are re-applied to app.ini on every container start by environment-to-ini, so a hand-edit of app.ini alone is overwritten.

git push over SSH says "Permission denied (publickey)". Three usual causes: the key was added to the wrong account, you're hitting the host's sshd on port 22 instead of the container on 222, or the container's SSH server never started. Test which daemon answers with ssh -p 222 -T git@git.example.com — Gitea replies with a greeting naming your username. A plain Permission denied from port 22 means you reached the host.

Pushes fail over HTTPS with a 413 or hang on large repositories. The reverse proxy is capping the request body. Raise the limit (nginx: client_max_body_size 512m) or push over SSH, which the proxy never sees.

Web UI is up but avatars, attachments, or LFS are missing after a restore. The custom directory didn't come back. Repositories and the database alone are not a complete restore — ./gitea/gitea has to be there too.

Permission errors on /data after moving the volume. The container runs as UID/GID 1000 by default. If the host directory is owned by someone else, chown -R 1000:1000 ./gitea (or set USER_UID/USER_GID to match) and recreate.

Verification + next steps

You're done when you can: load https://git.example.com with a valid certificate, sign in as your admin account with 2FA on, confirm open registration is closed in a private window, create a repository, push to it over both HTTPS and SSH, open a pull request, and produce a gitea dump you have restored at least once.

From there, the natural next steps are enabling Gitea Actions with a runner for CI, turning on the package registry to host your own container images, and wiring notifications into your chat. If you're weighing forks, the Gitea vs Forgejo comparison covers the split. For the ranked host picks under a forge like this, see Best VPS for Gitea.

Next steps

How to self-host Gitea →More self-hosted code hosting tools →Best VPS for Gitea →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.