How to Deploy Gitea on a VPS
Updated Aug 2026
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.
- 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)
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 — a Hetzner CX22 or a Kamatera instance sized to taste both handle that 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
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 passthrough section of the
Gitea Docker installation docs
step by step, 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:
- Disable open registration. In
app.ini, under[service], setDISABLE_REGISTRATION = true(or addGITEA__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. - Turn on two-factor auth for your own account under Settings → Security, and require it for admins.
- Decide whether anonymous browsing is allowed.
REQUIRE_SIGNIN_VIEW = trueunder[service]makes the whole instance private — the right setting for a company forge, the wrong one if you publish open source. - 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.dbfor SQLite, or apg_dumpfor 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 carriesconf/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.