Self-hosted Gitea Git server running with Docker and PostgreSQL on a VPS
Self-hosting

Gitea Docker Install: Self-Hosted Git Server with CI

Every time you type git push, your code lands on someone else’s computer. For open source work that is the whole point, but for client projects, internal tools, infrastructure scripts and anything covered by a contract or by GDPR, it is fair to ask whether one big platform on another continent should hold the only remote copy. The good news is that a small VPS can run a complete Git forge: web interface, pull requests, issues, SSH access, automatic mirrors of your GitHub repositories and even CI pipelines, all on hardware you control and for less than a couple of seats on a paid plan.

In this guide we walk through a complete gitea docker install on a fresh Debian 13 or Ubuntu 24.04 VPS. Gitea is a lightweight, self-hosted Git service written in Go and published under the MIT license. The project lives at gitea.com and its source code is at github.com/go-gitea/gitea . By the end, you will have Gitea running with a PostgreSQL database, served over HTTPS, accepting Git over SSH on port 2222 while your normal SSH login keeps working on port 22, mirroring repositories from GitHub on a schedule, and running simple CI jobs with Gitea Actions.

What Gitea is and why we pair it with PostgreSQL

Gitea gives you most of what people actually use GitHub for: repositories with a web code browser, branches and tags, pull requests with reviews, issues and milestones, a wiki, releases, organizations and teams, a package registry and, since version 1.19, a CI system that understands GitHub Actions style workflows. It is a single Go program, so it starts in seconds and is happy with a fraction of the memory that heavier forges need.

Under the hood, Gitea keeps two kinds of data. Your repositories are stored as ordinary bare Git repositories on disk, exactly what git init --bare would create. Everything else, meaning users, permissions, issues, pull request comments, mirror schedules and CI run history, lives in a database.

Gitea can use SQLite, a database that is just a file, and for a single person that works fine. As soon as several people push at the same time, a CI runner polls the API every few seconds and mirrors sync in the background, PostgreSQL is the better choice: it handles concurrent writes gracefully, gives you clean consistent backups with pg_dump, and makes later migrations painless. The cost is about 50 to 80 MB of extra RAM, which is a good trade.

The vocabulary, in two minutes

If Docker is new to you, these are the only terms you need for this guide:

  • Image: a packaged, read-only template of an application with everything it needs to run, for example gitea/gitea:1.25.
  • Container: a running instance of an image, isolated from the rest of the system. You can delete and recreate it at any time without touching your data, as long as the data lives in a volume.
  • Volume: a folder on the VPS that is mounted inside the container. Repositories, configuration and database files live there, so they survive upgrades and restarts.
  • Port: a number that identifies a network service on a machine. SSH normally uses 22, HTTP uses 80 and HTTPS uses 443. Two programs cannot listen on the same port at the same time, which is why Gitea’s own SSH server will use 2222.
  • Docker Compose: a tool that reads one YAML file describing several containers and starts them together with docker compose up -d.
  • Reverse proxy: a web server that sits in front of your application, obtains HTTPS certificates and forwards requests to the app on a private port. We use Caddy because it handles certificates automatically.

What you need

  • A KVM VPS with at least 1 vCPU and 1 GB RAM for Gitea alone, or 2 vCPU and 2 GB RAM if you plan to run CI jobs. Plan 20 GB of disk or more, depending on repository sizes.
  • Debian 13 or Ubuntu 24.04, freshly installed, and a user with sudo rights that you can log in with over SSH.
  • A domain or subdomain you control, for example git.example.com, with an A record (and AAAA if you use IPv6) pointing to the VPS IP address.
  • An SSH key pair on your own computer (we create one in step 6 if you do not have it).
  • Optionally, a GitHub personal access token if you want to mirror private repositories.
  • About 45 minutes.

A note on containers inside containers: this guide assumes a KVM VPS. Docker inside an unprivileged LXC container only works if nesting is enabled for that container, and UFW may fail to load its firewall rules there. If you are on an LXC plan, ask support to enable nesting first, or choose a KVM VPS for this project.

Throughout the article we use git.example.com. Replace it with your real domain everywhere, including inside the files we create.

How the pieces fit together

Before typing anything, it helps to see where each request goes. Your browser talks to Caddy on port 443. Caddy holds the TLS certificate and forwards the request to Gitea on port 3000, which we bind only to 127.0.0.1, so it is never reachable directly from the internet. Git over SSH takes a different path: port 2222 on the VPS is mapped straight to the SSH server that runs inside the Gitea container. The host’s own SSH server on port 22 is not touched at all, so your admin login keeps working exactly as before.

How the Gitea stack fits togetherYour laptopbrowser + gitGitHubmirror sourceUFW firewall: 22, 80, 443, 2222VPS: Debian 13 / Ubuntu 24.04Host sshd :22admin loginCaddy :80/:443HTTPS + TLSPort 2222Git over SSHDocker Compose projectGiteaweb :3000sshd :22 insidePostgreSQL 17users, issues,repo metadataact_runnerpolls for jobsCI job containersnode:20-bookwormVolumes on disk./gitea ./postgres ./runnermirror sync
Web traffic goes through Caddy, Git over SSH uses port 2222, and port 22 stays reserved for your own admin login. Port 3000 is only reachable from the VPS itself.

The procedure at a glance

We split the work into eight steps. Each one ends with a check, so if something goes wrong you know exactly where to look.

Gitea setup: 8 steps from a fresh VPS to CI1Prepare VPSDocker, DNS,UFW firewall2Compose projectfolders, .env,compose.yaml3Start stackGitea andPostgreSQL up4HTTPS (Caddy)reverse proxy,free TLS cert5Web installeradmin account,install locked6Git over SSHport 2222 nextto host sshd7Mirror GitHubpull mirrorswith auto sync8Gitea Actionsact_runner andfirst workflow
Steps 1 to 4 build the platform, steps 5 and 6 make Gitea usable, steps 7 and 8 add mirrors and CI. Each box matches a "Step" heading below.

Step 1: Prepare the VPS

Start by updating the system, so we do not build on top of outdated packages:

sudo apt update && sudo apt upgrade -y

If the upgrade installed a new kernel, reboot before going further. Here Debian and Ubuntu behave differently: Ubuntu creates the file /var/run/reboot-required when an update needs a restart, so you can check it with the command below. Debian does not create that file by default, so on Debian simply reboot if you saw a new linux-image package in the upgrade output.

[ -f /var/run/reboot-required ] && echo "Reboot needed" || echo "No reboot flag"

Next, install Docker Engine and the Compose v2 plugin from Docker’s official repository. Both distributions ship their own Docker packages, but the upstream repository gives you current versions of docker compose. The block below works on both systems because it reads the distribution name (debian or ubuntu) and the codename (trixie or noble) from /etc/os-release:

sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Check that everything works. The first command downloads a tiny test image and should print a message starting with “Hello from Docker!”. The second should print something like Docker Compose version v2.x.y:

sudo docker run --rm hello-world
docker compose version

Now confirm that your domain already points to the VPS. The command should print the public IP of the server. If it prints nothing, wait for DNS to propagate before step 4, because Caddy cannot get a certificate for a name that does not resolve yet:

getent hosts git.example.com

Finally, the firewall. Ubuntu ships UFW preinstalled but inactive, while Debian does not install it at all, so the first line is only needed on Debian (running it on Ubuntu does no harm). We allow SSH first, otherwise enabling the firewall could lock you out:

sudo apt install -y ufw
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 2222/tcp
sudo ufw enable
sudo ufw status

If your SSH server listens on a port other than 22, allow that port instead. The status output should list the four ports as ALLOW.

Step 2: Create the Compose project

We keep everything for Gitea in one folder, /opt/gitea, owned by your user. Inside it, three subfolders will hold the persistent data:

sudo mkdir -p /opt/gitea
sudo chown "$USER":"$USER" /opt/gitea
cd /opt/gitea
mkdir -p gitea postgres runner
id -u; id -g

The last command prints your numeric user and group IDs. On a fresh VPS with a single sudo user they are usually 1000 and 1000. If you see different numbers, use them for USER_UID and USER_GID in the file below, so that the repository files on disk belong to you and not to a random ID.

Passwords should never be written directly into compose.yaml. Instead, we generate a strong random password and store it in a .env file, which Docker Compose reads automatically. The chmod 600 makes it readable only by you:

cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
RUNNER_TOKEN=
EOF
chmod 600 .env
cat .env

RUNNER_TOKEN stays empty for now, we fill it in step 8. Now create the Compose file with nano compose.yaml and paste:

services:
  server:
    image: gitea/gitea:1.25
    container_name: gitea
    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}
      - GITEA__server__DOMAIN=git.example.com
      - GITEA__server__SSH_DOMAIN=git.example.com
      - GITEA__server__ROOT_URL=https://git.example.com/
      - GITEA__server__SSH_PORT=2222
      - GITEA__service__DISABLE_REGISTRATION=true
    volumes:
      - ./gitea:/data
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "2222:22"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    container_name: gitea-db
    restart: unless-stopped
    environment:
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=gitea
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
      interval: 10s
      timeout: 5s
      retries: 5

If you prefer not to edit the domain by hand, replace it in one go (use your own domain in the command):

sed -i 's/git\.example\.com/git.yourdomain.ro/g' compose.yaml

What this file does, piece by piece:

  • GITEA__section__KEY variables are written into Gitea’s configuration file (app.ini) on every start. This is the cleanest way to configure Gitea in Docker, because the settings live next to the rest of your stack.
  • SSH_PORT=2222 does not change where SSH listens inside the container (still 22). It tells Gitea which port to display in clone URLs, which must match the published port 2222:22.
  • 127.0.0.1:3000:3000 publishes the web interface only on the loopback interface. Only Caddy, running on the same VPS, can reach it.
  • depends_on with service_healthy makes Gitea wait until PostgreSQL actually accepts connections, instead of crashing on the first boot.
  • No version: key: current Docker Compose ignores it, so we leave it out.

About image tags: gitea/gitea:1.25 follows the 1.25 release line, so a later pull brings bug fixes but never a surprise major upgrade. Check the Gitea releases page and use the newest 1.x line available when you read this. For the database we pin postgres:17 on purpose. Using :latest would eventually pull a new major version, and PostgreSQL major upgrades require a dump and restore. The postgres:18 image also changed where data lives inside the container, so do not simply bump the tag on an existing setup.

Step 3: Start the stack (Gitea and PostgreSQL)

From /opt/gitea, start both containers in the background:

sudo docker compose up -d
sudo docker compose ps

The first run downloads the images, which takes a minute. After that, ps should show something like:

NAME       IMAGE              SERVICE   STATUS
gitea      gitea/gitea:1.25   server    Up 20 seconds
gitea-db   postgres:17        db        Up 31 seconds (healthy)

Look at Gitea’s logs to make sure it found the database and started its web server. Press Ctrl+C to stop following the log:

sudo docker compose logs -f server

You are looking for a line containing Starting new Web server: tcp:0.0.0.0:3000. Then test the web port locally. The answer should be HTTP/1.1 200 OK, which is the installation page waiting for you:

curl -sI http://127.0.0.1:3000 | head -n 1

Step 4: Add HTTPS with Caddy

Caddy is a web server that requests and renews Let’s Encrypt certificates on its own. It is packaged in both Debian 13 and Ubuntu 24.04, with the same service name (caddy) and the same configuration file (/etc/caddy/Caddyfile). Install it and replace the default configuration:

sudo apt install -y caddy
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
git.example.com {
	reverse_proxy 127.0.0.1:3000
}
EOF
sudo systemctl reload caddy

Remember to use your domain instead of git.example.com. Within a few seconds, Caddy contacts Let’s Encrypt, proves that it controls the domain over port 80 and installs the certificate. Check it:

curl -sI https://git.example.com | head -n 1

A line like HTTP/2 200 means HTTPS works. If you get an error, read the Caddy logs with sudo journalctl -u caddy --no-pager -n 50, the reason is almost always DNS or a closed port 80.

Why Caddy rather than Nginx here? Besides automatic certificates, Caddy has no default limit on request size. Nginx limits request bodies to 1 MB unless you raise client_max_body_size, which breaks pushes of larger commits over HTTPS, a classic first-day surprise.

Step 5: Run the web installer

Do this step right after step 4: until the installer is completed, anyone who opens your domain could fill it in. Open https://git.example.com in your browser. Thanks to the environment variables, most fields are already filled in:

  1. Database Settings: PostgreSQL, host db:5432, user and database gitea, password already set. Leave them as they are.
  2. General Settings: set a Site Title. Check that Server Domain is your domain, SSH Server Port is 2222 and Gitea Base URL is https://git.example.com/.
  3. Optional Settings, Server and Third-Party Service Settings: “Disable Self-Registration” should already be ticked. Tick “Require Sign-In to View Pages” too if the instance is private.
  4. Administrator Account Settings: expand it and create your admin user. Note that admin is a reserved name in Gitea, so pick something like your own name.

Click “Install Gitea”. After a few seconds you are logged in as administrator. Gitea has now written its configuration with INSTALL_LOCK = true, so the installer can never be shown again. You can see the file on the host:

sudo grep -A3 '^\[security\]' /opt/gitea/gitea/gitea/conf/app.ini

From now on, for settings that also appear in compose.yaml, edit the Compose file and run sudo docker compose up -d, not app.ini directly, because the environment variables win at every start.

Step 6: Enable Git over SSH

Gitea now has two SSH servers next to each other on the same VPS, and that is exactly what we want: the host’s sshd on port 22 for you as administrator, and the SSH server inside the Gitea container on port 2222 for Git operations only. The container’s server does not give anyone a shell, it only lets Gitea check which key belongs to which user and run Git commands.

On your own computer (not on the VPS), create a key if you do not have one, then print the public part:

ssh-keygen -t ed25519 -C "you@laptop"
cat ~/.ssh/id_ed25519.pub

In Gitea, open your avatar menu, go to Settings, then SSH / GPG Keys, click Add Key and paste the line that starts with ssh-ed25519. Then test the connection:

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

The first time, SSH asks you to accept a new host key. This is the key of the container’s SSH server, different from the VPS’s own key, and that is normal: SSH stores keys per host and port, so the two never conflict. The keys are kept in ./gitea/ssh, so they stay the same across container restarts and upgrades. The expected answer is:

Hi there, yourname! You've successfully authenticated with the key named you@laptop, but Gitea does not provide shell access.

Typing -p 2222 all the time gets old quickly. Add this to ~/.ssh/config on your computer:

Host git.example.com
    Port 2222
    User git
    IdentityFile ~/.ssh/id_ed25519

Now the short clone syntax works. Create an empty repository in the web interface (the “+” menu, “New Repository”), then push an existing project to it:

cd ~/projects/myproject
git remote add origin git@git.example.com:yourname/myproject.git
git push -u origin main

If you would rather push first and have the repository created automatically, add GITEA__repository__ENABLE_PUSH_CREATE_USER=true to the environment list and run sudo docker compose up -d. And if you absolutely want clone URLs without a port, Gitea supports “SSH passthrough”, where the host’s sshd on port 22 forwards Git connections into the container. It needs a git user on the host and a small wrapper script, the official documentation at docs.gitea.com describes it. For most teams, port 2222 plus the ~/.ssh/config entry above is simpler and has fewer moving parts.

Step 7: Mirror GitHub repositories

A pull mirror is a copy of a remote repository that Gitea refreshes on a schedule. It is useful as an off-site backup of your GitHub projects, as a fast local cache for CI, or as the first stage of a gradual migration.

In the web interface, click “+”, then New Migration, and choose GitHub. Paste the repository URL, for example https://github.com/yourname/project.git, and tick This repository will be a mirror. For private repositories, create a fine-grained personal access token on GitHub with read-only access to “Contents” for the repositories you need, and paste it into the token field. Even for public repositories a token helps, because unauthenticated GitHub API requests are rate limited to 60 per hour.

Two things to know about mirrors: they synchronize code (branches, tags, commits), but issues and pull requests are only copied in a one-time migration, without the mirror checkbox. And the default sync interval is 8 hours. You can change it per repository under Settings, then Mirror Settings, trigger “Synchronize Now” manually at any time, or change the default for new mirrors with GITEA__mirror__DEFAULT_INTERVAL=1h in the Compose file. The minimum allowed interval is 10 minutes.

If you have many repositories, the API is faster than clicking. Create a Gitea token under Settings, Applications (with write access to repositories), then:

curl -s -X POST "https://git.example.com/api/v1/repos/migrate" \
  -H "Authorization: token YOUR_GITEA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "clone_addr": "https://github.com/yourname/project.git",
    "repo_name": "project",
    "repo_owner": "yourname",
    "service": "github",
    "mirror": true,
    "mirror_interval": "8h0m0s",
    "private": true
  }'

The response is a JSON description of the new repository. For private sources, add "auth_token": "YOUR_GITHUB_TOKEN". The opposite direction exists too: a push mirror (in the same Mirror Settings page) sends every change from Gitea to GitHub, which is handy if Gitea becomes your main home and GitHub just a public showcase.

Step 8: Set up Gitea Actions

Gitea Actions is Gitea’s built-in CI. It reads workflow files with almost the same syntax as GitHub Actions, and it is enabled by default in current versions. What it needs is a runner, a separate program that asks Gitea “do you have work for me?”, starts a fresh container for each job, runs the steps and reports back. The official runner is called act_runner.

First, get a registration token: go to Site Administration, Actions, Runners, click Create new Runner and copy the token. Put it in .env:

cd /opt/gitea
nano .env    # set RUNNER_TOKEN=the-token-you-copied

Then add this service to compose.yaml, under services:, at the same indentation as db::

  runner:
    image: gitea/act_runner:0.2.11
    container_name: gitea-runner
    restart: unless-stopped
    depends_on:
      - server
    environment:
      - GITEA_INSTANCE_URL=https://git.example.com
      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
      - GITEA_RUNNER_NAME=vps-runner
      - GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm
    volumes:
      - ./runner:/data
      - /var/run/docker.sock:/var/run/docker.sock

The label line maps the name ubuntu-latest, which workflows ask for in runs-on, to the node:20-bookworm image, a Debian based image with Git and Node.js, the two things most common actions need. As with Gitea, check the act_runner releases for a newer version than 0.2.11 when you read this. Start the runner and read its log:

sudo docker compose up -d runner
sudo docker compose logs runner

Look for Runner registered successfully. The registration is saved in ./runner/.runner, so the token is only used the first time. Back in Site Administration, the runner appears with status Idle.

Now give it some work. In any repository (make sure Settings, Repository, Enable Repository Actions is ticked), create the file .gitea/workflows/ci.yaml:

name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Show what we are building
        run: |
          echo "Commit: ${{ gitea.sha }}"
          git log -1 --oneline
          node --version

      - name: Make sure the project has a README
        run: test -f README.md

Commit and push it. Open the Actions tab of the repository: within a few seconds a run appears, turns yellow while it works and green when it succeeds. The first run is slower because the runner downloads the node:20-bookworm image and the checkout action (which Gitea fetches from GitHub by default). Replace the last step with your real tests, linters or build commands, and you have working CI.

How much RAM does a Gitea Docker install need?

One of Gitea’s biggest selling points is how little it asks for. The numbers below are typical values for a small team with a few dozen repositories; your exact figures will vary with repository sizes, the number of users and what your CI jobs do.

Approximate RAM usage of a Gitea Docker installGitea + PostgreSQL~250 MB+ act_runner (idle)~290 MBDuring a CI job~900 MBGitLab CE (reference)~4000 MB01000200030004000RAM in MB (approximate, containers idle unless noted)SetupTypical values for a small team, your numbers will vary
Gitea with PostgreSQL fits comfortably in 1 GB of RAM. CI jobs are what really consume memory, so plan 2 GB or more if you enable Actions.

In practice: 1 GB of RAM is enough for Gitea and PostgreSQL for a handful of users. If you enable Actions, choose 2 GB or more, because a single npm install or a Go build can easily take several hundred megabytes. A small swap file is a cheap safety net on small plans, but it does not replace real RAM for builds.

Troubleshooting and common mistakes

Caddy returns “502 Bad Gateway”. Caddy is running but cannot reach Gitea. Check sudo docker compose ps and the logs from step 3. Make sure the Caddyfile points to 127.0.0.1:3000, the same port as in compose.yaml.

No HTTPS certificate. Almost always DNS (the domain does not point to the VPS yet) or port 80 blocked. Let’s Encrypt needs port 80 open for the challenge, even though your users only use 443. Check with getent hosts and sudo ufw status.

“Permission denied (publickey)” when pushing. Check three things: you are connecting to port 2222 and not 22 (run ssh -vT -p 2222 git@git.example.com to see which key is offered), the key was added to the right Gitea user, and the user in the URL is literally git, not your own username.

Clone URLs show the wrong port or domain. The repository page builds URLs from SSH_DOMAIN, SSH_PORT and ROOT_URL. Fix them in compose.yaml and run sudo docker compose up -d.

PostgreSQL says “password authentication failed”. The POSTGRES_PASSWORD variable is only used when the database folder is empty, at the very first start. If you changed .env later, the database still has the old password. Either put the old value back, or change it inside PostgreSQL with sudo docker compose exec db psql -U gitea -c "ALTER USER gitea PASSWORD 'new-password';" so it matches .env.

Permission errors in the Gitea logs. USER_UID/USER_GID do not match the owner of ./gitea. Set them to the values from id -u and id -g, then run sudo docker compose up -d.

HTTPS clone asks for a password and rejects yours. Once two-factor authentication is enabled, Git over HTTPS needs an access token instead of your password. SSH is usually more convenient anyway.

The runner does not register. Check that the token in .env is correct and that the container can reach https://git.example.com (test with sudo docker compose exec runner wget -qO- https://git.example.com/api/healthz). If you changed the token after a failed attempt, delete ./runner/.runner and restart the runner.

Jobs stay “Waiting” forever. The runs-on value in the workflow does not match any runner label. With our configuration it must be ubuntu-latest.

Security notes

  • Docker bypasses UFW for published ports. Docker writes its own firewall rules, so a port published as "3000:3000" would be reachable from the internet even if UFW says otherwise. That is why we publish the web port only on 127.0.0.1. Port 2222 is published on all interfaces on purpose.
  • The runner has root-level power. Mounting /var/run/docker.sock lets the runner start any container, which is equivalent to root on the VPS. Only run workflows from people you trust, keep registration disabled, and do not run CI for pull requests from unknown forks. For untrusted code, put the runner on a separate, disposable VPS.
  • Protect the admin account. Enable two-factor authentication under Settings, Security, and use a regular user for daily work.
  • Keep the installer closed. Complete step 5 right after step 4, so nobody else can configure your instance.
  • Back up both halves. Repositories are on disk, metadata is in PostgreSQL, and you need both. A simple nightly routine:
cd /opt/gitea
mkdir -p backups
sudo docker compose exec -T db pg_dump -U gitea gitea | gzip > "backups/gitea-db-$(date +%F).sql.gz"
sudo tar czf "backups/gitea-files-$(date +%F).tar.gz" gitea

Copy the backups folder off the server, ideally to another location. A backup on the same disk does not survive a lost disk.

  • Update deliberately. To update Gitea, take a backup, read the release notes, change the tag in compose.yaml if you move to a new 1.x line, then run sudo docker compose pull && sudo docker compose up -d. Never change the PostgreSQL major version without a dump and restore.

So, is self-hosting Git worth it?

If you want your code, issues and CI under your own control, with predictable costs and data that stays in a Romanian data center, Gitea is the easiest serious option available today. It runs comfortably on a small server, the Docker setup is easy to back up and move, and features like mirrors and Actions mean you do not give up much compared to the big platforms. If you need the full enterprise feature set or massive CI capacity, a heavier forge or a hosted service may still fit better.

You can start right now on a VPS from us: a 2 GB plan runs everything in this guide, including CI. If you would rather have someone else handle installation, backups, updates and monitoring, our server administration team can set up and maintain your Gitea instance for you.

Frequently asked questions

Is Gitea a good GitHub alternative for a small team?

For private repositories, code review, issues and simple CI, yes. You lose the social network side of GitHub (stars, discovery, the huge public Actions marketplace), but Gitea can use most public actions directly, and push mirrors let you keep a public copy on GitHub if you need visibility.

Can I use SQLite instead of PostgreSQL?

Yes. Remove the db service and set GITEA__database__DB_TYPE=sqlite3. It is perfectly fine for one or two users. We recommend PostgreSQL as soon as several people and a CI runner use the instance at the same time.

Can Gitea use port 22 for SSH instead of 2222?

Only if the host’s own SSH server moves to another port, or if you configure SSH passthrough, where the host’s sshd forwards Git connections to the container. Both work, but keeping 2222 for Git and 22 for administration is the simplest and safest setup.

Are Gitea Actions compatible with GitHub Actions workflows?

Largely. The syntax is the same, most popular actions work, and the github context is available (with gitea as an alias). Differences show up in features tied to GitHub’s infrastructure, such as certain caching services or hosted runner images, so test complex workflows before relying on them.

What about Forgejo?

Forgejo is a community fork of Gitea with a very similar feature set and a compatible Docker setup. Everything in this guide applies with small changes to image names and some configuration keys. Choose the project whose governance and release pace you prefer.

Trusted By & Member Of

We are proud members of leading internet infrastructure organizations.

RIPE NCC MANRS PeeringDB RoTLD DSIX SBIX 4IXP LOCIX Euro-IX RIPE NCC MANRS PeeringDB RoTLD DSIX SBIX 4IXP LOCIX Euro-IX