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.ymlwith the Forgejo image in a folder (web bound to 127.0.0.1, SSH on port 222), rundocker 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 withforgejo-cli actions registerand add its service to the compose file. Backups come fromforgejo dump, but SSH host keys are not in it, and on restore you must build the database fromforgejo-db.sql, not reuse thegitea.dbfile from the archive.
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.
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:
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.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.
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.
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 | This is the only variant we tested. We did not try PostgreSQL or MySQL |
Repository root path, User to run as | keep | Matches how the image is built |
Server domain | your domain or the server address ( | 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 ( | 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.
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.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.
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.
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.
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.$ sudo chown 1000:1000 runner/config.yml && sudo chmod 600 runner/config.ymlThe 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.
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).
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, | Your code and your people | Tested, works |
| 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 |
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.orgIf 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.
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.
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.
git push over SSH from outside, keep 222 and open it in UFW yourself.'222:22' with '127.0.0.1:222:22', the way we did for the web port.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.
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.
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.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 ( | Nothing special |
| yes | Fix the addresses if you are moving |
The server's SSH host keys ( | no | Copy separately (trap 1) |
| no, they are not in the archive listing | Keep them with the backup, remembering that the runner config holds a secret |
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.
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.
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):
fjadmin, an administrator;fjadmin/demo, SSH address ssh://[email protected]:223/fjadmin/demo.git;6412cca second run after network fix and f04b88f first commit with CI;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.
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:
: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.docker compose pull, then docker compose up -d. Check the version with the same curl and open the dashboard.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/.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 |
Caddy with | A real HTTPS certificate on a domain |
Migrating a public repository with history and branches | Issues, pull requests, wiki, mirrors, private repositories, LFS |
| 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 |
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.
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.
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.
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.
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.
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.
127.0.0.1:3000 and expose it only through Caddy with HTTPS. Open or close SSH port 222 deliberately.forgejo-cli actions register), not with the deprecated forgejo-runner register.docker_host: "-" the socket does not reach the jobs themselves, and we did not enable automount.forgejo/ssh separately, or clients will see a changed-key warning.forgejo-db.sql, create .ssh beforehand (owner 1000, mode 700) and do not run regenerate keys through docker compose exec.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.