← Back to Blog

Forgejo on a VPS: Your Own GitHub for a Small Team

Published · by RS Computers

Forgejo Git CI/CD

Forgejo is a free, self-hosted Git server that gives a small team most of what it uses GitHub for: private repositories, pull requests, issues, code review, a package registry and CI pipelines called Forgejo Actions. On a VPS it runs as one 117 MB program with a SQLite database, and in our lab the whole stack (Forgejo, an HTTPS proxy, Docker and a CI runner) used 227 MB of RAM at rest. This guide is an onboarding plan: what the admin does on day 0, what each developer does on day 1, then CI, GitHub mirrors and a tested restore. We ran every command below on Debian 13 in our lab on 9 October 2026.

Key facts, checked on 9 October 2026:

The plan: who does what

A Git server for 3 to 15 people needs one admin for about an hour, then ten minutes per developer. This is the order that worked for us:

StepWhoTime
Install Forgejo, HTTPS and the firewallAdmin, as rootAbout 15 minutes
Create accounts and the teamAdminAbout 1 minute per person
Add an SSH key, make a token, first pushEach developer5 to 10 minutes
Install a CI runner and a first workflowAdminLab: first job 31 seconds (image download), later jobs 3 seconds
Mirror repositories from GitHubAnyone with rights in the organizationLab: 5.2 seconds for a 37 MB repository
Nightly backup and a restore testAdminLab: dump 0.54 seconds, restore about 10 seconds

Pick the server size first

Forgejo itself is light. CI is what grows: every job runs in a container, and the Node.js image our workflow used took 1.14 GB of disk on its own. Memory in use in our lab, not counting file cache:

What was runningRAM in use
Forgejo, Caddy and OpenSSH at rest165 MB
The same plus Docker and forgejo-runner at rest227 MB
During a small CI job301 MB peak
Five developers cloning a 37 MB repository at once over SSH644 MB peak, all five done in 8 seconds

For Forgejo alone, a VPS Micro (2 vCPU, 2 GB RAM, 40 GB NVMe) is enough for a small team. If the CI runner lives on the same server, start with a VPS Mini (4 vCPU, 4 GB, 80 GB), because builds compete with Git for CPU. For heavy builds, give the runner its own server, such as a VDS Small (4 vCPU, 8 GB, 240 GB), which is also the safer layout. You can move up a plan later from the client area with a short reboot.

Day 0, admin: install Forgejo from the official binary

Forgejo also ships as a Docker image (codeberg.org/forgejo/forgejo:16.0.5), but the binary is simpler: one file, one systemd service, Git over the normal SSH port. We followed the official binary guide on a fresh Debian 13 server.

Download the binary and check its signature

# as root
apt update
apt install -y git git-lfs curl wget gnupg jq sudo
cd /root
wget https://code.forgejo.org/forgejo/forgejo/releases/download/v16.0.5/forgejo-16.0.5-linux-amd64
wget https://code.forgejo.org/forgejo/forgejo/releases/download/v16.0.5/forgejo-16.0.5-linux-amd64.asc
gpg --keyserver keys.openpgp.org --recv EB114F5E6C0DC2BCDD183550A4B61A2DC5923710
gpg --verify forgejo-16.0.5-linux-amd64.asc forgejo-16.0.5-linux-amd64

Look for Good signature from "Forgejo <contact@forgejo.org>". The extra warning that the key "is not certified with a trusted signature" is normal. If you see BAD signature, stop.

# as root
cp forgejo-16.0.5-linux-amd64 /usr/local/bin/forgejo
chmod 755 /usr/local/bin/forgejo
forgejo --version

Expected output: forgejo version 16.0.5+gitea-1.22.0 (release name 16.0.5). To stay on the LTS line instead, use the same steps with the newest 15.0 version number from the download page.

Create the git user and the folders

Forgejo runs as a system user called git, the name you see in clone addresses such as git@git.example.com.

# as root
adduser --system --shell /bin/bash --gecos 'Git Version Control' --group --disabled-password --home /home/git git
mkdir /var/lib/forgejo
chown git:git /var/lib/forgejo && chmod 750 /var/lib/forgejo
mkdir /etc/forgejo
chown root:git /etc/forgejo && chmod 770 /etc/forgejo
wget -O /etc/systemd/system/forgejo.service https://codeberg.org/forgejo/forgejo/raw/branch/forgejo/contrib/systemd/forgejo.service

Write the configuration instead of clicking through the installer

The official guide opens a web installer on port 3000 at this point. Anyone who reaches that page before you do can set up your server, so we wrote the configuration file directly and locked the installer. Replace git.example.com with your own domain, whose DNS A and AAAA records point at the server:

# as root
cat > /etc/forgejo/app.ini <<INI
APP_NAME = Team Git
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/forgejo

[server]
DOMAIN = git.example.com
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
ROOT_URL = https://git.example.com/
SSH_DOMAIN = git.example.com
SSH_PORT = 22
DISABLE_SSH = false
START_SSH_SERVER = false
LFS_START_SERVER = true
LFS_JWT_SECRET = $(forgejo generate secret LFS_JWT_SECRET)

[database]
DB_TYPE = sqlite3
PATH = /var/lib/forgejo/data/forgejo.db

[security]
INSTALL_LOCK = true
SECRET_KEY = $(forgejo generate secret SECRET_KEY)
INTERNAL_TOKEN = $(forgejo generate secret INTERNAL_TOKEN)

[oauth2]
JWT_SECRET = $(forgejo generate secret JWT_SECRET)

[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true

[actions]
ENABLED = true
INI
chown root:git /etc/forgejo/app.ini
chmod 640 /etc/forgejo/app.ini
systemctl daemon-reload
systemctl enable --now forgejo
systemctl is-active forgejo

The $(forgejo generate secret ...) parts fill in random keys while the file is written. INSTALL_LOCK turns the web installer off, DISABLE_REGISTRATION means only the admin creates accounts, and REQUIRE_SIGNIN_VIEW hides everything from visitors who are not logged in. SQLite is a database stored in one file, which is plenty for a team of this size and makes backups simpler. The last command should print active.

Our first version of this file had no LFS_JWT_SECRET line, and Forgejo refused to start with save server.LFS_JWT_SECRET failed ... permission denied: it tried to write the missing key into a file it may only read. Generating the key up front fixes it.

Day 0, admin: HTTPS and the firewall

Forgejo now listens only on 127.0.0.1, so a reverse proxy has to face the internet and handle the TLS certificate. With Caddy that takes three lines. We cover installation, Nginx and Traefik in our reverse proxy and SSL guide.

# as root: apt install -y caddy, then put this in /etc/caddy/Caddyfile
git.example.com {
	reverse_proxy 127.0.0.1:3000
}

Run systemctl reload caddy afterwards. With a real domain, Caddy fetches a Let's Encrypt certificate by itself; our lab had no public domain, so we added tls internal inside the block to test, and curl -sI https://git.example.com/ answered HTTP/2 200. Then open only the ports you need:

# as root
apt install -y ufw
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status

Port 80 is for Let's Encrypt's domain check. Port 3000 stays closed.

SSH for git: share port 22 or give Forgejo its own

With the binary install, Git over SSH uses the server's normal OpenSSH on port 22. When a developer adds a key in the web interface, Forgejo writes it into /home/git/.ssh/authorized_keys with a forced command, so that key can run Git and nothing else. Your admin login keeps working on the same port, and clone addresses stay short: git@git.example.com:team/website.git. This is what we recommend.

The alternative is Forgejo's built-in SSH server on its own port, fully separate from the system's SSH. We tested it with three lines in [server]:

# as root, in /etc/forgejo/app.ini, then: systemctl restart forgejo
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
START_SSH_SERVER = true

After a restart, Forgejo listened on port 2222 and showed clone addresses like ssh://git@git.example.com:2222/team/website.git. Remember ufw allow 2222/tcp. With the Docker image, the official docs describe an "SSH passthrough" setup that forwards host port 22 into the container; we did not test it.

Day 1, every developer: account, key, first push

Admin: create the accounts and the team

Create yourself as admin first, then one account per person. Forgejo prints a random first password for each one, which you pass on privately:

# as root
cd /var/lib/forgejo
sudo -u git forgejo --config /etc/forgejo/app.ini admin user create --admin --username boss --email boss@example.com --random-password
sudo -u git forgejo --config /etc/forgejo/app.ini admin user create --username alice --email alice@example.com --random-password
sudo -u git forgejo --config /etc/forgejo/app.ini admin user list

Each create command prints generated random password is '...' and New user 'alice' has been successfully created!. Then log in to the web interface as the admin, create an organization (we called ours team), and inside it a team called "developers" with write access to all repositories. Add each person to that team. Repositories then belong to the organization, so nothing breaks when someone leaves.

Accounts made this way must change their password at first login. Until they do, Git over HTTPS fails with remote: Update your password, which confused us for a minute. Ask everyone to log in on the web once before anything else. In our lab we cleared the flag from the command line instead:

# as root
cd /var/lib/forgejo
sudo -u git forgejo --config /etc/forgejo/app.ini admin user must-change-password --unset alice

Developer: SSH key, token and the first push

Log in, change the password and turn on two-factor login under Settings, Security. Then, on your own computer:

# on your own computer, as yourself
ssh-keygen -t ed25519 -C "alice@laptop"
cat ~/.ssh/id_ed25519.pub

Paste the line that starts with ssh-ed25519 into Settings, SSH / GPG keys, Add key. The admin creates the empty website repository in the organization beforehand, using the plus menu at the top of the page. Then check that the key works and push a first commit:

# on your own computer, as yourself
ssh -T git@git.example.com
mkdir website && cd website
git init -b main
echo "# Team website" > README.md
git add README.md
git commit -m "First commit"
git remote add origin git@git.example.com:team/website.git
git push -u origin main

The test answers Hi there, alice! You've successfully authenticated with the key named alice-laptop, but Forgejo does not provide shell access., and the push ends with * [new branch] main -> main.

Where SSH is blocked, use HTTPS with an access token from Settings, Applications (read and write on repositories). When Git asks for a password, paste the token:

# on your own computer, as yourself
git config --global credential.helper store
git clone https://git.example.com/team/website.git

Our HTTPS clone and push worked through Caddy with a token limited to write:repository. The store helper keeps the token in a plain file in your home folder; on a shared computer, use your system's keychain instead.

Forgejo Actions: a runner and a first workflow

Forgejo Actions uses the same workflow syntax as GitHub Actions. The jobs run in a separate program, Forgejo Runner, each in its own Docker container. The docs warn that a runner "performs remote code execution": whoever can push a workflow can run code on that machine. For a trusted team, the same server is fine; if outsiders can open pull requests, use a separate VPS. Our guide to a staging server with a CI runner shows that layout.

Install Docker and the runner

# as root
apt install -y docker.io
cd /root
wget -O forgejo-runner https://code.forgejo.org/forgejo/runner/releases/download/v13.2.0/forgejo-runner-13.2.0-linux-amd64
wget -O forgejo-runner.asc https://code.forgejo.org/forgejo/runner/releases/download/v13.2.0/forgejo-runner-13.2.0-linux-amd64.asc
gpg --verify forgejo-runner.asc forgejo-runner
install -m 755 forgejo-runner /usr/local/bin/forgejo-runner
forgejo-runner -v
useradd --create-home runner
usermod -aG docker runner

Expect Good signature again (it is the same Forgejo key) and forgejo-runner version v13.2.0. Membership of the docker group lets the runner start containers, and in practice gives it root-level power over the machine.

Register the runner

The web interface can create a runner (organization Settings, Actions, Runners), but we used the command-line route from the registration docs. Make a 40-character secret and register a runner for the team organization:

# as root, on the Forgejo server
openssl rand -hex 20
cd /var/lib/forgejo
sudo -u git forgejo --config /etc/forgejo/app.ini forgejo-cli actions register --name vps-runner-1 --scope team --secret PASTE_THE_40_CHARACTERS

The register command prints a UUID such as 62663534-6666-3339-6564-626562326561. Now create the runner's configuration file:

# as root
sudo -u runner sh -c 'cd /home/runner && forgejo-runner generate-config > runner-config.yml'
nano /home/runner/runner-config.yml

Change two places in that file. Under runner:, replace labels: [] with a label; under server:, fill in the connection:

# /home/runner/runner-config.yml, the two parts you edit as root
runner:
  labels:
    - "docker:docker://node:22-bookworm"

server:
  connections:
    forgejo:
      url: https://git.example.com/
      uuid: THE_UUID_FROM_THE_REGISTER_COMMAND
      token: THE_SAME_40_CHARACTERS

The label means "a job that asks for runs-on: docker runs in the node:22-bookworm image". Then install the official service file and start the runner:

# as root
chmod 600 /home/runner/runner-config.yml
chown runner:runner /home/runner/runner-config.yml
wget -O /etc/systemd/system/forgejo-runner.service https://code.forgejo.org/forgejo/runner/raw/branch/main/contrib/forgejo-runner.service
systemctl daemon-reload
systemctl enable --now forgejo-runner
journalctl -u forgejo-runner -n 5

The log should say runner: vps-runner-1, with version: v13.2.0, with labels: [docker], ephemeral: false, declared successfully. The service file expects exactly /home/runner/runner-config.yml, so keep that name. Our lab also had to tell job containers where the fake git.example.com lived and trust Caddy's internal certificate; a server with a real domain and a Let's Encrypt certificate should not need that.

A first workflow

Any developer adds this file to a repository and pushes it. Workflows live in .forgejo/workflows/. A repository without that folder falls back to .github/workflows/, which helps when you move a project over.

# .forgejo/workflows/ci.yml, added by any developer
on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: docker
    steps:
      - uses: actions/checkout@v4
      - name: Show where we are
        run: |
          echo "Commit $GITHUB_SHA on $GITHUB_REF_NAME"
          node --version
      - name: README must exist
        run: test -s README.md

Our first run took 31 seconds, mostly downloading the 1.14 GB image; every later run took 3 seconds. The job log showed Commit 066b5c5d... on main and v22.23.3. actions/checkout@v4 came from Forgejo's own mirror at data.forgejo.org. Many GitHub workflows run unchanged, but note the runner 13.0 changes: set-output, set-env and add-path no longer work, and GITEA_ variables are gone.

Mirror your GitHub repositories

A pull mirror is a read-only copy that Forgejo refreshes from GitHub every 8 hours by default. It protects you against projects you depend on vanishing, and lets a team move gradually. In the web interface, use the plus menu, New migration, Git, paste the repository address and tick "This repository will be a mirror". For a public repository no GitHub account is needed. In our lab, mirroring the Caddy web server's repository (37 MB on disk) took 5.2 seconds. We set up the mirrors and triggered a manual sync through Forgejo's API, which does the same as the web form and the "Synchronize now" button.

Choosing GitHub instead of Git in that menu also brings over issues, pull requests and releases, and a push mirror does the reverse, sending every change from Forgejo to GitHub. Both need a GitHub access token, so we did not test them.

Backups with forgejo dump, and a restore we actually ran

forgejo dump packs the repositories, the database, attachments and the configuration into one archive. Schedule it every night:

# as root
mkdir -p /var/backups/forgejo
chown git:git /var/backups/forgejo && chmod 700 /var/backups/forgejo
apt install -y cron
cat > /etc/cron.d/forgejo-backup <<'EOF'
30 3 * * * git cd /var/backups/forgejo && /usr/local/bin/forgejo dump --config /etc/forgejo/app.ini --type tar.gz --file /var/backups/forgejo/forgejo-$(date +\%F).tar.gz --quiet && find /var/backups/forgejo -name 'forgejo-*.tar.gz' -mtime +7 -delete
EOF

This keeps seven days of archives. Our dump of three repositories took 0.54 seconds and produced 57 MB. A backup on the same server does not survive the loss of that server, so copy the folder somewhere else every night, for example with restic as in our off-site backup guide.

The restore test

We deleted /var/lib/forgejo, the configuration and the SSH keys file, then restored. Loading the dump's SQL file into a new SQLite database, the usual advice, failed with hundreds of no such function: unistr errors: Debian 13's sqlite3 3.46.1 lacks a function the dump uses. Forgejo's upgrade guide warns that this SQL has "serious long standing open bugs". With SQLite you can skip it, because the archive holds the database file too. This worked, with Forgejo installed as above, stopped, and /var/lib/forgejo empty:

# as root
systemctl stop forgejo
apt install -y sqlite3
mkdir /tmp/restore && cd /tmp/restore
tar xzf /var/backups/forgejo/forgejo-2026-10-09.tar.gz
install -o root -g git -m 640 app.ini /etc/forgejo/app.ini
mv data /var/lib/forgejo/data
mkdir -p /var/lib/forgejo/data/forgejo-repositories /var/lib/forgejo/log
mv repos/* /var/lib/forgejo/data/forgejo-repositories/
chown -R git:git /var/lib/forgejo
sudo -u git sqlite3 /var/lib/forgejo/data/forgejo.db 'PRAGMA integrity_check;'
cd /var/lib/forgejo
sudo -u git forgejo --config /etc/forgejo/app.ini admin regenerate hooks
sudo -u git forgejo --config /etc/forgejo/app.ini admin regenerate keys
systemctl start forgejo
sudo -u git forgejo --config /etc/forgejo/app.ini doctor check --all --log-file /tmp/doctor.log

The integrity check printed ok, and regenerate keys rebuilt the SSH keys file so nobody had to re-add a key. Without the log folder the doctor complained stat /var/lib/forgejo/log: no such file or directory; with it, all 27 checks passed. Alice's next pull and push worked, her HTTPS token was still valid, and CI ran green. The restore took about 10 seconds. Repeat this test on a spare VPS before you depend on the backups.

For upgrades (a new stable version every quarter, or once a year on LTS): take a dump, swap the binary, restart, run doctor check --all.

Forgejo or Gitea?

Forgejo started in October 2022 as a fork of Gitea. In Forgejo's words, that was after "a for profit company took over the Gitea project", and it became a hard fork in early 2024, so the code bases now differ (Forgejo's comparison page). The differences that matter for a small team are who controls the project and the licence:

ForgejoGitea
Run byA community project under Codeberg e.V., a German non-profitOpen source, "with leadership and commercial support from CommitGo" (about.gitea.com)
LicenceGPLv3 or later from 9.0 on (announced August 2024)MIT
Paid editionsNoneGitea Enterprise and Gitea Cloud
ReleasesQuarterly, plus one LTS a year with about 15 months of supportSeparate schedule, separate code base since 2024

For a team that only uses Forgejo, the GPL changes nothing day to day; it matters only if you distribute a modified version.

What GitHub Team would cost instead

On 9 October 2026, GitHub's pricing page listed Team at $4 per user per month, marked as the rate for the first 12 months, with 3,000 Actions minutes and 2 GB of package storage included. For a team of 10 that is $40 a month, or $480 a year, before extra minutes. GitHub charges $0.006 per minute for a 2-core Linux runner beyond the included minutes, and runs on your own self-hosted runners are free (GitHub Actions billing). A Forgejo server costs the same for 3 users or 30, and CI minutes are limited only by your CPU. You give up GitHub's reach, Dependabot and Codespaces, which is why many teams keep public projects on GitHub and private work on Forgejo, joined by mirrors.

Frequently asked questions

How much RAM does Forgejo need?

In our lab on Debian 13, Forgejo 16.0.5 with Caddy and OpenSSH used 165 MB of RAM at rest, and 227 MB with Docker and a CI runner added. Five parallel clones of a 37 MB repository peaked at 644 MB, so 2 GB is a comfortable minimum, or 4 GB with CI on the same server.

Can Forgejo run GitHub Actions workflows?

Mostly yes. Forgejo Actions uses the same workflow syntax, falls back to .github/workflows/ when a repository has no .forgejo/workflows/ folder, and fetches common actions such as actions/checkout from its own mirror. Workflows that rely on removed commands such as set-output, or on GitHub-only services, need small changes.

Should I use SQLite or PostgreSQL for Forgejo?

SQLite is enough for a small team and makes backups simpler, because the database is one file inside the dump. If you run PostgreSQL instead, back it up with its own tool (pg_dump), because Forgejo's upgrade guide warns that the SQL copy inside forgejo dump has serious long-standing bugs. We tested only SQLite.

Can Forgejo and my admin SSH share port 22?

Yes. With the binary install, Forgejo adds each developer's key to the git user's authorized_keys file with a forced command, so those keys can only run Git. Your own admin login is unaffected. If you prefer full separation, Forgejo's built-in SSH server can listen on another port such as 2222.

Is Forgejo the same as Gitea?

No longer. Forgejo forked from Gitea in 2022 and became a hard fork in early 2024. Forgejo is run under the non-profit Codeberg e.V. and licensed GPLv3 or later, while Gitea is MIT-licensed and has commercial support from CommitGo.

How do I back up and restore Forgejo?

Run forgejo dump as the git user every night and copy the archive off the server. To restore a SQLite setup, put the archive's data folder and repositories back under /var/lib/forgejo, use its database file rather than its SQL file, regenerate hooks and keys, and run forgejo doctor check --all.

Get your team's forge running

Start with the signed binary, Caddy and SSH keys, add a runner when the team wants CI, and restore a backup once so you know it works. We run KVM servers in Amsterdam, Dublin and Prishtina, each with its own IPv4 and IPv6 address, and availability by city is on the plans page. If you would like us to set Forgejo up for your team, message us on Telegram and we will quote the job.

← All articles

Chat on Telegram