3 November 2026

Forgejo on a VPS: your own Git server with CI in one evening

A Git server with CI on your own VPS: Docker Compose, a runner, HTTPS, a GitHub move and a backup whose restore we actually tested.

Forgejo on a VPS gives you a private Git server with repositories, issues, pull requests and built-in CI that runs your tests on every push. This guide walks the whole route from an empty server to a green build: Docker Compose, the web installer, an SSH key, a runner, HTTPS with Caddy, moving a repository over from GitHub, and a backup that we did not just create but restored onto a second instance. We ran the main chain (install, key, push, runner, CI, backup and restore) command by command on 2026-11-03. We did not run ssh -L for the tunnel, ssh-keygen, Caddy on ports 80 and 443, or sed with an https address (we tested a close variant of the last two). Everything adapted or untested is marked in the text and in a separate list near the end.

In short. Put a docker-compose.yml with the Forgejo image in a folder (web bound to 127.0.0.1, SSH on port 222), run docker compose up -d, open the web installer and set the SSH port to 222. Add an SSH key to your profile and make the first push. For CI, generate a shared secret, register the runner with forgejo-cli actions register and add its service to the compose file. Backups come from forgejo dump, but SSH host keys are not in it, and on restore you must build the database from forgejo-db.sql, not reuse the gitea.db file from the archive.

Why run your own Git server, and who it is for

You run your own server when you want code and builds under your control: private repositories on terms you set, builds on a machine you own, and the freedom to pack up and leave at any time. The price is that updates, backups and security become your job. If a hosted service is enough for you and you would rather not carry that, stay where you are. If you are a developer, a small team or an agency that has to keep client code in-house, read on.

Forgejo is self-hosted Git software: a web interface, users, issues and pull requests. It is a fork of Gitea, and the traces show: the version string reads 17.0.1+gitea-1.22.0 and the image still has /usr/local/bin/gitea in it. Forgejo Actions is the built-in CI. You commit a workflow file to the repository, and on each push a runner executes it in a container. The format is close to GitHub Actions but not identical, so some existing workflows need edits. If you are looking for a GitHub alternative that you can host entirely on your own server, this is one that works.

The HIP order form also offers a ready-made Forgejo app. Here we install by hand so that you see every step and pick the version yourself.

What you get: Forgejo on a VPS and what it takes

You end up with Forgejo (SQLite inside), a runner that builds your project in a container, and Caddy with HTTPS in front. The cost in resources is below. The numbers come from docker stats at idle and docker images on our test server; yours will differ a little.

Component

Image size

RAM at idle

Forgejo 17.0.1

291 MB

100-220 MiB (we saw 116, 137, 165 and 217 MiB at different moments)

Runner v13.2.1

66.8 MB

about 9-15 MiB

Job image node:22-bookworm

1.64 GB (pulled on the first job)

about 3 MiB per container running a simple command

Caddy (step 5)

not measured

about 18 MiB

At idle, Forgejo and the runner together took 150-230 MiB for us (Forgejo 137 or 217 MiB, the runner 9-13 MiB). The peak total across all lab containers during a CI job was 328 MiB, but that sum also included a second Forgejo instance from the restore test and Caddy, so the stack itself fits in about 230 MiB. Plan for at least 512 MiB of free RAM for this stack, leaving room for the system and heavier builds (our advice, not a measurement). After install, two repositories and three CI runs the data folder was 29 MB, the runner folder 628 KB. The sizing advice is our own conclusion, not a benchmark: 1 vCPU and 2 GB of RAM should do for a personal repository or a small team, and heavy builds need headroom in RAM and disk. The job image alone is 1.64 GB, so keep the article on cleaning Docker off a full disk within reach.

You need:

  • A VPS with Ubuntu. We tested 24.04.5 and did not run 22.04.
  • Docker with Compose v2: docker compose version must answer (ours was Docker 29.1.3 with Compose v2.40.3). If Compose is new to you, start with the article on several services in Docker Compose.
  • SSH access with sudo rights.
  • A domain for step 5. Until then a server address and an SSH tunnel are enough.

How we tested: the lab was a separate Docker daemon with an empty store on a HIP-HOSTING VPS, so no production containers were at risk. In the outputs the working folder path is shortened to ~/. The docker commands are written without sudo: we assume you are root or in the docker group.

Step 1. Forgejo in Docker Compose

Create a folder named forgejo-stack and put a docker-compose.yml in it with the content below. The folder name matters: Compose derives the network name from it (ours became forgejo-stack_forgejo), and you will need that name in step 4.

The lines that are not obvious at first glance:

  • image: ...forgejo:17 is the tag for the 17.x line. For us it resolved to 17.0.1. The mirror data.forgejo.org/forgejo/forgejo:17 pulls the same way.
  • USER_UID=1000 and USER_GID=1000 are the numeric user and group IDs (Linux tells file owners apart by these numbers) that Forgejo uses inside the container. Files in ./forgejo will belong to them, so the folder must be owned by 1000:1000 too. That is the next command.
  • restart: always brings the container back after a crash and after a reboot of the server.
  • ./forgejo:/data keeps everything worth saving (repositories, database, config) in an ordinary folder next to the compose file. You can recreate the container and the data stays.
  • 127.0.0.1:3000:3000 makes the web interface listen on the server itself only. It is invisible from outside, and that is deliberate: until HTTPS is in front, a login form should not face the internet. Caddy will take that place later.
  • 222:22: the SSH server inside the container listens on 22, and we publish it as 222 because port 22 on the host already belongs to the system SSH you use to log in to the machine. As written, this port listens on every address of the server (more on that in step 5).

Create the data folder with the right owner and start the stack. Without the chown, Forgejo has no permission to write into a folder you created yourself.

Docker pulls the image (291 MB). Right after startup the web answers with HTTP 200. Check that the container is alive and the ports are what we wanted:

Read the PORTS column. The web is bound to 127.0.0.1, while 222 is bound to 0.0.0.0 and [::], which means the whole internet can reach it unless a firewall stands in the way. We come back to that. The time in the STATUS column will be different on your server.

Step 2. The web installer

The web is closed to the internet, so for now we reach it through an SSH tunnel: your computer forwards a local port to the server over an SSH connection. From your laptop run ssh -L 13000:127.0.0.1:3000 user@SERVER-IP and open http://127.0.0.1:13000 in a browser. Tunnels are covered in the article on SSH port forwarding. That is why every screenshot below shows 127.0.0.1:13000 in the address and not a domain.

You land on a page titled "Initial configuration". Most fields can stay as they are.

Field

What to do

Why

Database type / Path

keep SQLite3 and /data/gitea/gitea.db

This is the only variant we tested. We did not try PostgreSQL or MySQL

Repository root path, User to run as

keep /data/git/repositories and git

Matches how the image is built

Server domain

your domain or the server address (127.0.0.1 in the lab)

It ends up in clone addresses

SSH server port

set 222

This is the port clients will see. Inside the container SSH still listens on 22

HTTP listen port

keep 3000

Matches the port in compose

Base URL

the address you open the site at (http://127.0.0.1:13000/ in the lab)

We replace it with the https address of your domain in step 5

Disable self-registration

keep it on

Otherwise anyone who finds the address can create an account. With a runner attached that is especially unpleasant (see runner security)

Enable update checker

on by default, we left it

It looks up a DNS TXT record at release.forgejo.org from time to time and tells you about new versions

Administrator account settings

expand it and create the admin

The block is collapsed by default. Skip it and you have no one to log in as

The installer with its fields filled in. The browser address above the frame was 127.0.0.1:13000 through the SSH tunnel.

We created an administrator called fjadmin with the address [email protected]. You should use a real address and a long password. Press "Install Forgejo" and wait: the dashboard took about 20 seconds to appear for us. After installation forgejo/gitea/conf/app.ini gains ROOT_URL, SSH_DOMAIN, SSH_PORT = 222 and INSTALL_LOCK = true. We will touch that file again.

The dashboard after installation. The version, 17.0.1, is visible in the footer.

You can also ask the API for the version, which is handy after upgrades. It works only after the install: before that the API answers Not Found.

Step 3. An SSH key and the first push

The key lets you push code without a password: the private half stays with you and the public half goes to Forgejo. If you have no key pair, create one with ssh-keygen -t ed25519 and set a passphrase. The public key is the content of the file ending in .pub. Add it to your profile: Settings → SSH / GPG keys → Add key.

The SSH / GPG keys page. The key named lab is added; Forgejo shows its fingerprint and the date it was last used.

Next, confirm the server recognises you. We ran the commands on the server itself, hence the address 127.0.0.1. From your own computer use the server address or domain, but then port 222 has to be reachable (see step 5). On the first connection SSH asks whether to trust the server key, and answering yes is expected.

  • -T asks for no terminal, since Forgejo never gives you a shell.
  • -p 222 is the port we published.
  • The user is always git, not your own name: Forgejo identifies people by their key.

The "Hi there, ..." reply means success. Now create an empty repository named demo without a README (the "+" button in the header → New repository; we created it through the API) and push some code. Keep the repository in a separate folder such as ~/demo, not inside forgejo-stack, or the README and commits end up in the stack folder. Run the docker compose commands from forgejo-stack.

Look for * [new branch] main -> main among the output lines. The port 222 in the URL is mandatory: without it git goes to 22 and lands on the system SSH.

Step 4. The Actions runner: your own CI

Forgejo does not execute workflows itself. A separate program does, the runner. It connects to Forgejo, takes jobs and runs each one in a fresh container. The two have to be introduced to each other, and this is where outdated guides cause trouble.

Registration: a shared secret instead of a token

Older write-ups register the runner with forgejo-runner register and a token copied from the web interface. The Forgejo documentation marks forgejo-runner register as deprecated. On v13.2.1 we took the route from the official compose example: you invent a shared secret, tell Forgejo "a runner with this secret exists", and put the same secret into the runner's config. The registration token from the web interface does not work as that secret; the same example says so. The secret is 40 hex characters, and the runner's UUID is built from the first 16 characters of that secret.

Do all of this in one terminal window: the variables SECRET and UUID are needed here and in the config below.

  • openssl rand -hex 20 produces 20 random bytes as 40 hex characters.
  • -u git runs the command as the git user, the one Forgejo lives under.
  • --name lab-runner is the name in the runner list; --keep-labels keeps labels already stored in Forgejo when you register again; the labels themselves are set in the runner config below.

We did not save the output of the last command. Success shows up in the admin area and in the runner log below. Do not publish the secret anywhere.

The runner config

Now the runner's settings file. Create runner/config.yml in any editor with the content below. On the card the uuid and token values are masked with asterisks: put in what echo "$UUID" and echo "$SECRET" print.

  • capacity: 1 means the runner takes one job at a time, which protects memory on a small server.
  • timeout: 3h stops a job that runs longer than three hours.
  • labels: docker:docker://... defines the label docker and the image jobs run in. A workflow says runs-on: docker and lands here. The image is pulled on the first run.
  • container.network is the Compose network Forgejo lives in. Without it a job container cannot see http://forgejo:3000. The name is the folder name plus _forgejo; on your server check it with docker network ls.
  • docker_host: "-" means: do not hand the Docker socket to the job containers themselves (per the Forgejo documentation this is the default). If you need Docker inside jobs, read the documentation section on Docker access.
  • File permissions: it holds a secret, so only the owner should read it. The command below sets that.
$ sudo chown 1000:1000 runner/config.yml && sudo chmod 600 runner/config.yml

The runner service in compose

The runner needs access to the server's Docker, otherwise it has nothing to start job containers with. We hand it the Docker socket. First find the group that owns the socket: the runner's user has to belong to it.

Ours is 112, yours may be another number. Add a runner service to the end of docker-compose.yml, putting your number into group_add. Here is the whole file after the edit (you already know the first service):

The runner follows the same pattern as the server: data in ./runner, the shared network, a restart on failure, and depends_on so it starts after the Forgejo container (it does not wait for Forgejo to be ready). The socket line passes the server's Docker into the runner container, and group_add lets the runner's user read it. Start it and look:

The runner log should say it registered. Ours read runner: lab-runner, with version: v13.2.1, with labels: [docker], ephemeral: false, declared successfully, followed by [poller] launched. In the web interface the runner appears in the admin area (Admin settings → Actions → Runners): name lab-runner, label docker, type Global, status Idle.

The runner is registered and waiting for jobs. There is also a "Create new runner" button; we did not take that route.

The first workflow and its result

Workflows live in the repository under .forgejo/workflows. Put the simplest one there: it checks out the code and prints the Node version, the machine name and a file listing. If those lines appear, the whole chain (push, runner, container) works.

  • on: [push] runs the workflow on every push of commits.
  • runs-on: docker is the runner label from the config.
  • actions/checkout@v4 is a ready-made step that downloads the repository code into the container. Forgejo fetches it from https://data.forgejo.org/actions/checkout.
  • name: is the step's caption in the interface. Without it Forgejo shows a multi-line run: | as one long line in the step header, so name your steps.

Commit the file and push. The runner picks the job up on its own, and within seconds it shows on the repository's Actions tab.

Those lines are the runner log after it had already accepted jobs; your task numbers will differ. On our lab the first run hung on "Set up job" for about two and a half minutes and failed, which is why run #1 is red in the screenshot. The cause was the lab, not Forgejo (details below). After we fixed it, the remaining runs passed, the last one, #4, in 2 seconds, because the image was already on disk by then. On your very first run, add the time it takes to pull 1.64 GB.

The run list: #1 failed on the lab's network hang, #2-4 succeeded.

The log of run #4 with every step expanded; each step carries its own name from name:.

In the log you can see v22.23.4 (the Node version in the image), the hello from the runner on line with the container name, and a file listing that includes .forgejo, .git and README.md (the screenshot also shows restored.txt, left from the restore check).

Runner security

A runner executes arbitrary code by design: whatever a workflow says, it runs on your server. That is remote code execution, and here it is not a bug but the way the feature works. The Docker socket is mounted into the runner container: the runner creates the job containers, so it controls the host's Docker daemon. Our config has docker_host: "-", which per the Forgejo documentation means the socket is not handed to the job containers themselves (that is the default). The automount mode, which does hand it to jobs, gives "no isolation at all" according to the documentation: a job could, for example, mount the host's root. We did not enable it. Even without it the runner executes arbitrary code (the documentation says the entire purpose of Forgejo Actions is remote code execution), so this setup is acceptable only for trusted users and your own repositories. Do not enable open registration, and do not let foreign repositories use this runner.

Option

When it fits

Our status

Host Docker socket in the runner container only, docker_host: "-" (as in this guide)

Your code and your people

Tested, works

automount: the socket is given to jobs too

Only if you understand the risk: no isolation per the documentation

Not enabled

Docker-in-Docker (DinD)

When you want isolation from the host daemon; described in the Forgejo documentation (the section on Docker access)

Not tested

A separate virtual machine or server for the runner

When repositories may contain someone else's code (the documentation advises isolating the runner in its own virtual machine)

Not tested

If "Set up job" hangs

Most likely the job container has no route to the internet and cannot fetch the image or a step. Test that directly, without Forgejo:

$ docker run --rm alpine wget -qO- https://data.forgejo.org

If the command hangs or returns a network error, look at Docker networking or the firewall. In our lab that was the cause: a forwarding policy in UFW and a separate Docker without rules of its own. On an ordinary server with Docker from the package it usually does not happen; we did not test that separately. The second common cause is a forgotten container.network in the config: then the job cannot reach http://forgejo:3000.

Step 5. A domain and HTTPS with Caddy

Caddy is a web server that obtains and renews certificates by itself. Here it acts as a reverse proxy: it accepts HTTPS requests on your domain and hands them to Forgejo. We covered installing Caddy on the host in the article about Caddy; this time it runs in a container on the same network, so it can reach Forgejo by name.

Domain first: create an A record pointing to your server's IP (the domain guide explains how). Then a Caddyfile that is two lines of substance:

Replace git.example.com with your domain. forgejo:3000 is the container name and its port inside the shared network, which is why we do not need the web port on the host at all and can leave 127.0.0.1:3000 as it is. Start it:

$ docker run -d --name caddy --restart unless-stopped --network forgejo-stack_forgejo -p 80:80 -p 443:443 -v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" -v caddy_data:/data caddy:2
  • --network forgejo-stack_forgejo is the same network Forgejo is on.
  • -p 80:80 -p 443:443 are the ports Caddy uses to talk to the internet and to obtain a certificate.
  • -v caddy_data:/data is a volume where Caddy keeps its certificates so it does not ask for new ones after the container is recreated (the Caddy documentation recommends this).

Ports 80 and 443 must be open in the firewall (UFW, for example), and the A record must already point at the server, or no certificate will be issued.

A frank note on what we tested. We did not issue a real certificate for a real domain: the lab has no domain and no open ports 80 and 443. What Caddy does on a real domain (it fetches a certificate itself, usually from Let's Encrypt with a fallback, and renews it) we describe from the Caddy documentation. We did test everything else, the Caddy and Forgejo pair with a local certificate. For that the Caddyfile used the test name git.lab.test and the line tls internal, and Caddy listened on port 18443 on localhost.

The API answered through Caddy with code 200. The -k flag exists only for the lab: it skips certificate verification because tls internal issues a local one. On a real domain you need neither -k nor tls internal.

Now tell Forgejo itself that it lives at an https address. ROOT_URL in forgejo/gitea/conf/app.ini has to match the public address, or the clone links will be wrong. We changed ROOT_URL and SSH_PORT with sed in the same way during the restore, and it worked. SSH_DOMAIN sits next to them in the file, so look at it too.

Port 222: open it or close it

Remember the PORTS column? Port 222 is listened on by docker-proxy on all addresses (0.0.0.0:222 and [::]:222). On our lab UFW allowed only 22, so 222 was not visible from outside. On a server with no firewall it is open to everyone, and you should decide on purpose. Test it from another machine instead of taking our word for it.

  • If you need git push over SSH from outside, keep 222 and open it in UFW yourself.
  • If HTTPS cloning is enough, close it: in compose replace '222:22' with '127.0.0.1:222:22', the way we did for the web port.

Step 6. Moving over from GitHub

Forgejo can bring a repository over from elsewhere: the "+" button in the header → New migration, then a choice of source. We picked plain Git (the form "Migrate from Git"), which copies only the repository itself from a clone URL. Moving issues and pull requests goes through the tile of a specific service, and GitHub needs a token there; we did not test that.

The migration form. For a public repository the address and a name are enough.

We tried it on the public sample repository octocat/Hello-World, calling the migration API with service=git and mirror=false, so a plain copy and not a mirror. The result was fjadmin/hello-world: default branch master, three branches, history intact (7fd1a60 Merge pull request #6 ..., 7629413, 553c207 first commit).

The migrated repository: commits and branches are there.

What we did not test: moving issues, pull requests and wiki (that needs an access token in the source service), a mirror with auto-update, private repositories and LFS. We do not promise they behave the same. Start with one unimportant repository and compare the result with the original.

Step 7. A backup and restore you can trust

A backup nobody has restored is a hope. We made a dump, unpacked it into a new instance beside the old one and hit four traps. One of them (the SSH host keys) is mentioned in the official backup page but easy to miss; the other three we found ourselves.

How to make the backup

The forgejo dump command packs repositories, the database and settings into a single zip. Run it as the git user and write to /tmp inside the container, then copy the archive to the host:

  • -c ...app.ini says which config to start from.
  • -f ... is where to write the archive.
  • Success is the line Finished dumping in file. Our archive with two repositories (one of them the migrated Hello-World) came to 22 MB.

Inside the archive: app.ini, data/ (with gitea.db, conf, jwt, actions_log and more), forgejo-db.sql and repos/. Treat it like a password: it holds the config, secrets and all your private repositories. And do not leave it on the same server, because a backup next to the data disappears with the data. Copy it to another machine or to S3-compatible storage; how to organise that for a whole server is in the server backup guide.

What

In the dump?

What to do

Repositories, users, issues, Actions history

yes (repos/, forgejo-db.sql, data/)

Nothing special

app.ini

yes

Fix the addresses if you are moving

The server's SSH host keys (forgejo/ssh)

no

Copy separately (trap 1)

docker-compose.yml, runner/config.yml, Caddyfile

no, they are not in the archive listing

Keep them with the backup, remembering that the runner config holds a secret

How to restore

Below is the order that worked for us. We unpacked the dump into a new folder beside the live instance (ports 3001 and 223, container name forgejo-restore), so we changed the addresses in app.ini. If you restore at the same address as before, you do not need to.

First unpack the archive and lay the files out where Forgejo expects them. repos/ becomes git/repositories/, and the content of data/ goes into gitea/. In the address, 13001 is the SSH tunnel port leading to port 3001, just as 13000 led to 3000 in step 2.

Two more steps, and the traps live in them. The .ssh folder must exist in advance, owned by 1000 with mode 700. And we rebuild the database from the text dump: delete the gitea.db that came in the archive and create a new one from forgejo-db.sql using sqlite3 run inside the same image, so nothing has to be installed on the server.

The sqlite3 command prints nothing when all is well. Now start the stack and regenerate the server-side hooks, the small scripts inside repositories that connect a push to Forgejo; after the paths moved they need refreshing.

Four traps

1. SSH host keys are not in the dump. These are the server's own keys, the ones clients use to recognise they are talking to the same machine as before. A new instance generates fresh ones, and every user gets "REMOTE HOST IDENTIFICATION HAS CHANGED". The forgejo/ssh folder from the old server has to be copied separately. Here are the fingerprints in our lab: first the old instance (port 222) and the new one (223), then after the copy.

We copied the folder after the first start, with the container stopped, and the fingerprints matched. Putting it in place before the first launch is the more logical order, but we did not run it that way. Whatever you do, keep a copy of forgejo/ssh with every dump.

2. Do not use data/gitea.db from the archive as your database. The copy in the archive came out without some of the latest writes. Most likely they were still in the WAL journal (SQLite applies it later): we took the dump on a running Forgejo, and the official documentation advises shutting Forgejo down for the duration of a backup. We used that copy and got an Actions history of 1=running instead of the real 2=success 1=failure. That is why the recipe above builds the database from forgejo-db.sql with sqlite3.

3. The forgejo/git/.ssh folder must exist beforehand, owned by 1000 with mode 700. Otherwise the first start on a ready database kills the container with a fatal-level [F] error: inspect authorized_keys failed: open /data/git/.ssh/authorized_keys: permission denied. The reason is that the image entrypoint creates .ssh as root.

4. Do not run forgejo admin regenerate keys through docker compose exec. For us it wrote a line with the path /usr/local/bin/forgejo into authorized_keys, and on the next start the container refused to come up: Key on line 2 of /data/git/.ssh/authorized_keys does not exist in database. The log also offered two ways out: delete the file (Forgejo creates it again) or set SSH_ALLOW_UNEXPECTED_AUTHORIZED_KEYS=true. Without that command Forgejo builds authorized_keys itself, with a line like command="/usr/local/bin/gitea --config=/data/gitea/conf/app.ini serv key-1",..., and SSH works. Per the documentation, the file can be refreshed when needed by a cron task in the admin area (Admin → Monitor → Cron); we did not test that task. regenerate hooks caused no trouble.

What the check showed

We went through the restored instance point by point. Logging in as the admin works, the repository fjadmin/demo is there, and so are its commits. The Actions history is complete, 2=success 1=failure, not the 1=running we got by copying the ready-made database. This is what the restored instance's API returned (we parsed the answer with a small script):

  • user: fjadmin, an administrator;
  • repository: fjadmin/demo, SSH address ssh://[email protected]:223/fjadmin/demo.git;
  • commits: 6412cca second run after network fix and f04b88f first commit with CI;
  • Actions history: runs: 2=success 1=failure.

git clone over HTTP worked. After the host keys were carried over, SSH works as well: authorized_keys and the hooks are alive, and a push goes through.

Then we made a new commit and pushed it to the restored instance. The API listed 3cd3586 commit after restore as the latest commit. That is the actual test: the backup did not merely unpack, you can work in it.

One more check for anyone with CI. We rehearsed the whole route again on a clean folder, running the commands from this article in order, and on the "new server" copied the runner folder and the compose file with the runner next to the restored data. The runner came back with declared successfully without any new registration, and the push made after the restore was picked up by it and finished as success. The runner's registration travels with the database, but its config.yml and the secret in it do not, so keep that folder in your backup.

How to upgrade Forgejo

We did not test an upgrade between versions on the lab. What follows comes from how Docker Compose works and from the documentation, not from our results. A cautious order:

  1. Make a dump as above and copy it off the server. That is where you can return to.
  2. Read the release notes on the Forgejo site: a change of major version can bring steps this article does not have.
  3. Pin the tag. Our compose has :17, meaning "any 17.x", which moves by itself on the next pull. The tag 17.0 stays on the latest 17.0.x patch, while 17 follows the latest 17.x minor. For predictability pick the tag you want (your current version is in the page footer or in the /api/v1/version reply) and move to 18 by hand, following the official upgrade guide.
  4. Run docker compose pull, then docker compose up -d. Check the version with the same curl and open the dashboard.
  5. After it starts, run docker compose exec -u git server forgejo doctor check --all: the official guide asks for it, and we did not run it. The guide: forgejo.org/docs/latest/admin/upgrade/.

What we did not test

So that nothing surprises you on your own server, here is the honest list. Everything in it is ground where you will have to lean on the Forgejo documentation.

Tested in the lab

Not tested

Ubuntu 24.04.5, Docker 29.1.3, Compose v2.40.3

Ubuntu 22.04, ARM, a Windows runner

Forgejo 17.0.1 with SQLite

PostgreSQL and MySQL, the rootless image, other versions, upgrades between versions

Runner v13.2.1 via a shared secret and the Docker socket

The "Create new runner" path, the automount mode, DinD

Caddy with tls internal

A real HTTPS certificate on a domain

Migrating a public repository with history and branches

Issues, pull requests, wiki, mirrors, private repositories, LFS

forgejo dump and restore onto a new instance

Backing up the PostgreSQL variant, a cold backup by copying the folder, restoring onto another version, a dump with LFS and packages

(nothing)

Mail (SMTP), the package registry, the ready-made Forgejo app in the HIP order form

FAQ

How much RAM does Forgejo need on a VPS?

At idle Forgejo itself took 100 to 220 MiB for us and the runner about 9-15 MiB; together they stayed within 150-230 MiB, and the peak across all lab containers during a job was 328 MiB (counting a second Forgejo instance and Caddy). We consider 1 vCPU and 2 GB of RAM enough for a personal repository or a small team; that is a conclusion from measurements and not a benchmark, and heavy builds need more memory and disk.

Can I move issues and pull requests over from GitHub?

We tested only moving the repository itself with its history and branches. The plain "Migrate from Git" form copies the repository only. Moving issues, pull requests and wiki goes through the tile of a specific service and needs an access token there; we did not try it. Do not count on it until you have run it on a trial repository.

Is it safe to give the runner the Docker socket?

Only for code you trust. The socket is mounted into the runner container, which controls the host's Docker daemon; with docker_host: "-" (as in our config) it does not reach the job containers. The automount mode, which hands it to jobs too, offers no isolation per the documentation. Either way the runner executes whatever the workflows say. For foreign repositories the documentation suggests isolating the runner in a separate virtual machine or using Docker-in-Docker; we tested neither.

How do I back up Forgejo and trust the result?

Use forgejo dump and copy the archive off the server. Save the forgejo/ssh folder with the host keys separately, because the dump does not include it. And restore the archive once on a trial instance: build the database from forgejo-db.sql and do not take gitea.db from the archive.

What if a job hangs on "Set up job"?

Check that a container can reach the internet: docker run --rm alpine wget -qO- https://data.forgejo.org. If it gets no answer, look for the problem in Docker networking or the firewall. Also make sure the runner config names the Compose network in container.network.

Do I need a domain, and why is SSH on port 222?

For the first install you do not need a domain: an SSH tunnel to 127.0.0.1 is enough. HTTPS needs one, since that is what Caddy gets the certificate for. Port 222 is used because 22 already belongs to the server's system SSH, the one you log in with, not Forgejo's.

Takeaways

  • Forgejo and the runner together took 150-230 MiB of RAM at idle for us (the lab peak during a job was 328 MiB, counting a second instance and Caddy); the job image weighs 1.64 GB and is pulled on the first run.
  • Keep the web on 127.0.0.1:3000 and expose it only through Caddy with HTTPS. Open or close SSH port 222 deliberately.
  • We registered the runner with a shared secret (forgejo-cli actions register), not with the deprecated forgejo-runner register.
  • The runner runs any code in your workflows and its container controls the host's Docker: your own repositories and your own people only. With docker_host: "-" the socket does not reach the jobs themselves, and we did not enable automount.
  • The dump has no SSH host keys. Copy forgejo/ssh separately, or clients will see a changed-key warning.
  • On restore, build the database from forgejo-db.sql, create .ssh beforehand (owner 1000, mode 700) and do not run regenerate keys through docker compose exec.
  • A real HTTPS certificate on a domain, upgrades between versions and moving issues and pull requests are untested here.

Summary

One evening is realistic for the whole chain: compose, installer, key, runner and a first green build. The most valuable result is not Forgejo itself but confidence in the backup: you know the archive unpacks, and you know the four places where it usually breaks. If you need a server for this stack, a HIP-HOSTING VPS will do; size it by the measurements above. Before installing, take care of basic server hardening, and afterwards put the backup on a schedule.

PUBLISHED
3 November 2026
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU