Gitea is a full GitHub-shaped forge — repos, issues, pull requests, a package registry, and now its own CI — in one small Go binary. The web half installs in five minutes. The part that actually costs an evening is git-over-SSH, because your VPS's SSH port is already spoken for.
SQLite or Postgres, and when it actually matters
Gitea, unlike some self-hosted apps that only pretend SQLite is an option, genuinely supports it in production for small teams. A single maintainer or a handful of collaborators pushing to a dozen private repos will not notice the database at all — it is one file, backed up by copying it, and there is nothing else to run.
Move to Postgres once either becomes true: several people are pushing and opening pull requests at the same time and you want real concurrent writes, or you are running Gitea Actions on the same box, where the runner and the web UI can both hit the database while a job is in flight. Postgres also makes backups cleaner later — a pg_dump is a text stream you can diff and restore selectively, where a SQLite file backup is all-or-nothing.
There is no in-place conversion between the two: outgrowing SQLite means exporting what you can, or scripting a migration, rather than flipping a config flag — decide early if you already know you'll have more than one or two regular committers.
Sizing: 1 GiB alone, 2 GiB once Actions run
Gitea itself is light. The Go binary, a handful of goroutines, and either SQLite or a thin Postgres connection: 1 GiB of RAM is genuinely comfortable for a personal instance, Caddy included.
The number that changes this is Actions. A CI job is a short-lived container doing real work — compiling, running a test suite, building an image — and while it runs it competes for the same RAM as Gitea and Postgres. Turn Actions on and point a runner at the same VPS, and plan for 2 GiB as the realistic floor, more if your builds pull large base images or compile anything non-trivial. Disk follows the same pattern: repos are usually small, but the Docker layer cache a runner accumulates is not, so keep an eye on it once jobs run regularly.
The compose file
Pin the image tag rather than tracking latest, so an upgrade is something you choose, not something that happens on a restart — the comment on the image line below is where to check for the current minor.
services:
gitea:
image: gitea/gitea:1.22 # check hub.docker.com/r/gitea/gitea/tags for the current minor
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- 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__server__SSH_LISTEN_PORT=22
- 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"
volumes:
gitea_data:
The web port is bound to 127.0.0.1 on purpose, so only the reverse proxy on the same host can reach it. The SSH port is deliberately not loopback-bound, because it has to be reachable from wherever you push from; more on the number 2222 below.
To move the database to Postgres, add a second service and point Gitea at it:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=gitea
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
Add db_data: next to gitea_data: under the top-level volumes: block — Compose won't start a service that references an undeclared volume.
and add to the gitea service's environment, plus depends_on: [db]:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${DB_PASSWORD}
Put the password in a .env file next to the compose file, not inline in the YAML:
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
chmod 600 .env
Bring the stack up, then create the admin account from the CLI:
docker compose up -d
docker compose exec -u git gitea gitea admin user create --username <you> --password '<strong-password>' --email you@example.com --admin
GITEA__security__INSTALL_LOCK=true above skips Gitea's /install wizard — without it, that page sits open and unauthenticated until you finish it by hand. GITEA__service__DISABLE_REGISTRATION=true matters too: Gitea's default is open self-registration.
HTTPS with Caddy
git.example.com {
reverse_proxy 127.0.0.1:3000
}
That is the whole file. Caddy requests and renews the certificate for this hostname automatically as soon as it starts or reloads with this config, provided the A record already resolves to the machine — point DNS first, let it settle, and only then start Caddy.
Ports 80 and 443 aren't guaranteed forwarded either — some plans hand them to you by default, some don't, and Caddy needs at least one reachable to get a certificate. Check what your plan forwards before pointing DNS at the box; NAT IPv4, ports and forwarding is the same page the SSH story below sends you to.
Read this before you buy: the SSH port story
This is the part every other Gitea write-up glosses over. Your VPS almost certainly reaches SSH on a dedicated, non-standard port already — that is how sshd is exposed under NAT IPv4, and it is not something you can also hand to a container. Gitea's own SSH server needs a second forwarded port pointed at it, mapped to the container's internal port 22.
Concretely: your provider gives you a small number of forwarded ports besides the one used for host SSH. Say the next is 41023. You map it in compose as "41023:22" and set GITEA__server__SSH_PORT=41023 to match — but keep GITEA__server__SSH_LISTEN_PORT=22 right next to it, the way the compose file above already does. SSH_PORT only controls what Gitea advertises in clone URLs; SSH_LISTEN_PORT controls what it actually listens on inside the container, and it quietly defaults to whatever SSH_PORT is set to. Change one without pinning the other to 22, and Gitea's internal listener moves off port 22 too — nothing answers your "41023:22" mapping anymore, so git-over-SSH fails outright, connection refused, not just a wrong URL in the UI.
If you would rather not deal with a second forwarded port at all, git push/git clone over HTTPS with a personal access token works identically and needs nothing beyond the port Caddy already uses. Generate the token under Settings → Applications in Gitea's UI, and use it as the password when the CLI or credential helper asks. This is the honest fallback, not a downgrade — plenty of people run Gitea for years on HTTPS-only clones and never touch the SSH port.
Worth reading first: NAT IPv4 vs a dedicated IP and NAT IPv4, ports and forwarding.
Gitea Actions, and where the runner should live
Gitea Actions speaks most of the GitHub Actions workflow syntax, so .gitea/workflows/*.yml files using common actions mostly just work. It ships disabled; turn it on with GITEA__actions__ENABLED=true and restart.
Actions itself does no building — it dispatches jobs to act_runner, a separate process that registers against your instance and polls for work. Generate a registration token from inside the running container:
docker compose exec -u git gitea gitea actions generate-runner-token
Copy the printed token into .env as RUNNER_TOKEN=<token> — the runner block below reads it from there, and an empty value means a runner that never registers.
then point a runner at it:
runner:
image: gitea/act_runner:0.2.11 # check gitea.com/gitea/act_runner's tags for the current release
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=https://git.example.com
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
- GITEA_RUNNER_NAME=vps-runner
volumes:
- runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
Add runner_data: to that same volumes: block too (and db_data:, if you added Postgres).
That last mount is the thing to be honest about: giving the runner the Docker socket gives it effective root on the host, since it can start a container with the host filesystem mounted in. On a box that only runs Gitea and its own private repos, that's a reasonable trade. a self-hosted CI runner on a VPS covers sizing and isolation if your builds get heavier, or you want the runner on its own machine.
Backups with gitea dump
Gitea ships its own backup command, and it's the one to use — it snapshots the database, repos, hooks, and configuration together, instead of leaving you to reconstruct which copies were consistent with each other.
docker compose exec -u git gitea gitea dump -c /data/gitea/conf/app.ini
Run without a Postgres service attached, this also captures the SQLite file. The resulting zip lands inside the container's data directory; copy it off before it does anyone any good:
DUMP=$(docker compose exec -T gitea sh -c 'ls -t /data/gitea/gitea-dump-*.zip | head -n1')
docker cp "$(docker compose ps -q gitea):$DUMP" "./gitea-dump-$(date +%F).zip"
Copy that file off the VPS entirely — a dump on the same disk as the instance it came from is a file, not a backup. back up your VPS covers what off-machine backup means in practice. If you moved to Postgres, the dump still includes a database export, so a separate pg_dump is only needed for a faster point-in-time restore of a large database on its own.
Updates
docker compose pull
docker compose up -d
Bump the pinned tag deliberately, read the release notes for the version you're jumping to, and take a gitea dump immediately beforehand — Gitea runs migrations automatically on first start after an upgrade, and a migration you can roll back from is one where the backup came ten minutes earlier, not one you're improvising after the fact.
On overnight.host
Full disclosure: this is what we sell. If you want the repos without the sysadmin, the managed Gitea container comes with its own hostname and certificate — you get the app and a URL, not a root shell.
One-click apps — EUR 4 to EUR 12 a month, hosted in Germany (EU). Eight apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Each customer gets an isolated Docker network and volume, plus a hostname under apps.overnight.host on a real wildcard certificate. Memory and CPU are capped per plan by the container runtime.
You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.
Order one-click-gitea → · One-click apps overview
FAQ
Can I use SQLite for a real team, or is it just for testing?
For a small team it is a real, supported option, not a toy — Gitea's SQLite backend handles a handful of concurrent users comfortably. Move to Postgres once several people are pushing at once, or you're running Actions on the same box — both add write concurrency SQLite wasn't designed for.
Why can't I just use my VPS's normal SSH port for git?
Because it is already in use by sshd for logging into the machine itself, and one port cannot be forwarded to two different listeners. Gitea's own SSH server needs its own forwarded port, mapped to the container, with SSH_PORT set to match for correct clone URLs and SSH_LISTEN_PORT pinned to 22 so the container's own listener still matches that mapping.
Do I need a dedicated IPv4 for this?
No. A second forwarded port for Gitea's SSH server is enough, and if your provider doesn't hand you a spare one, HTTPS with a personal access token works identically with no extra port at all. A dedicated IPv4 only matters if you want a standard port number instead; on our plans that's arranged by e-mail.
Where should the Actions runner live?
On the same VPS is fine at small scale, as long as you budget the RAM for it and accept the runner's Docker socket access as host-level power. Once builds get heavy, or you want a boundary between the git server and whatever your CI jobs execute, move the runner to its own box — a self-hosted CI runner on a VPS walks through sizing and isolation for that case.
What does gitea dump actually back up?
The database (including a SQLite file, if that's what you're running), the repository data, custom configuration, hooks, and logs, bundled into one zip with -c pointing at your app.ini — the one command that guarantees the pieces are consistent with each other, which copying files by hand doesn't.
Written by the person who runs overnight.host: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at up.overnight.host.
Originally published at overnight.host — the canonical, kept-current version of this guide.
Top comments (0)