HostiServer
2026-10-09 11:19
Gitea / Forgejo in Docker: A Complete Self-Hosted Stack
📚 "Docker on a VPS" series, part 5 of 5:
- Docker on a VPS: installation and first container
- Docker Compose: running several services together
- Docker in production
- Docker + CI/CD: auto-deploy with GitHub Actions / GitLab CI
- Gitea / Forgejo in Docker: a complete self-hosted stack ← you are here
Gitea / Forgejo in Docker: a complete self-hosted stack
All four previous articles in the series relied on the code living somewhere external — on GitHub, GitLab, or any other external platform. Self-hosted Git removes that dependency entirely: full control over the code, privacy without exceptions for third-party services, no licensing restrictions for internal or closed projects, and along with that — your own Docker registry and your own CI, which don't count build minutes and don't limit private repositories.
By the end of this article, a full four-service platform will be running on a single VPS: Forgejo (the Git server itself and the web interface), PostgreSQL, act runner (the CI pipeline executor), and Nginx — all in one docker-compose.yml.
This article closes out two series at once. From the Docker series, it builds on everything covered earlier: Compose with multiple services (article 2), Nginx and SSL (article 3), the auto-deploy principle (article 4). From the "CI/CD from Scratch" series — on the concept of a self-hosted runner and the syntax of Actions-like pipelines.
2. Gitea vs Forgejo
Forgejo is a fork of Gitea that split off in 2022 over disagreements about the openness of project governance. It develops as a community-driven initiative under Codeberg's umbrella, while Gitea remains under separate leadership.
Functionally, both projects are practically identical today, and the API is fully compatible — database and repository migration in either direction goes through without losses. The difference is mostly in the Docker image names and the details of the governance model. Everything described further in this article applies to both options equally; Forgejo is used here as the main example.
3. Stack architecture
The stack consists of four services:
| Service | Role |
|---|---|
forgejo |
Web interface and the Git server itself |
db |
PostgreSQL — Forgejo's data store |
runner |
act runner — the CI pipeline executor |
nginx |
Reverse proxy and SSL termination |
The forgejo-net network isolates forgejo, db, and runner within one internal space; only nginx is exposed outward, through ports 80 and 443. Two volumes handle persistence: forgejo-data holds the repositories themselves, the configuration, and Git LFS objects, while pgdata holds the database. SSH for git operations is exposed separately on its own port (typically 2222 → 22 inside the container), or forwarded to the host through a separate mechanism — more on that below.
4. docker-compose.yml
The full stack in a single file:
services:
forgejo:
image: codeberg.org/forgejo/forgejo:9
environment:
FORGEJO__server__ROOT_URL: https://git.example.com
FORGEJO__server__SSH_DOMAIN: git.example.com
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
FORGEJO__database__NAME: forgejo
FORGEJO__database__USER: forgejo
FORGEJO__database__PASSWD: ${DB_PASSWORD}
volumes:
- forgejo-data:/data
ports:
- "2222:22"
depends_on:
db:
condition: service_healthy
networks: [forgejo-net]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: forgejo
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: forgejo
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"]
interval: 5s
timeout: 5s
retries: 10
networks: [forgejo-net]
restart: unless-stopped
runner:
image: code.forgejo.org/forgejo/runner:3
environment:
FORGEJO_INSTANCE_URL: https://git.example.com
FORGEJO_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
volumes:
- runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
networks: [forgejo-net]
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on: [forgejo]
networks: [forgejo-net]
restart: unless-stopped
volumes:
forgejo-data:
pgdata:
runner-data:
networks:
forgejo-net:
The forgejo dependency on db via condition: service_healthy works on the same principle as the app → db link in article 2 of this series: without a healthcheck, Forgejo might try to connect to the database before PostgreSQL is actually ready to accept connections.
Configuring Forgejo through environment variables with the FORGEJO__ prefix is the recommended approach, more convenient than bind-mounting a ready-made app.ini: all settings stay visible right inside docker-compose.yml, without a separate file that's easy to forget to sync when moving to another server. The key variables are SERVER__ROOT_URL (the public address), SERVER__SSH_DOMAIN (the domain for git clones over SSH), the database__* block with the type and access to PostgreSQL, and, if needed, a mailer section for email notifications. The runner service registers itself with Forgejo using a token (more on that in section 8) and keeps its state in its own volume.
5. SSH and cloning
By default, Forgejo inside the container listens for SSH on the standard port 22. Externally this port is mapped to 2222, so as not to conflict with the host's own system SSH server, which already occupies port 22 for administering the server.
In Forgejo's configuration this is reflected by the pair SSH_PORT=2222 and SSH_DOMAIN=git.example.com — it's exactly these values that determine what the clone command looks like for any repository:
git clone git@git.example.com:2222/user/repo.git
There's also an alternative route — SSH passthrough via AuthorizedKeysCommand on the host itself, where the host's system SSH forwards git requests into the container. This lets you keep the standard port 22 for git clones without the extra :2222 in the address, but it's noticeably more complex to set up than a simple port mapping.
6. First run and setup
On first start, Forgejo shows an Installation wizard right in the browser by default — several steps for configuring the database, the administrator, and general settings. This step can be skipped entirely by supplying all the needed values in advance through environment variables and adding INSTALL_LOCK=true — then the container starts up already ready to work, without the interactive wizard.
The first account registered in the system automatically becomes an administrator by default; alternatively, the admin can be set explicitly via FORGEJO__security__ADMIN_USER. Everything else — user SSH keys, organizations, repository visibility — is then configured through the regular web interface, just as in the cloud equivalents.
7. Nginx + SSL
The Nginx configuration here almost exactly repeats article 3 of the series: proxy_pass http://forgejo:3000; and the same set of headers — Host, X-Real-IP, X-Forwarded-Proto. SSL is obtained the same way, through Let's Encrypt and Certbot in webroot mode, with the same deploy hook for a reload after renewal.
One important nuance specifically for a Git server: Nginx proxies only web traffic (HTTP/HTTPS). It doesn't touch SSH traffic for git operations at all — that goes straight to port 2222, bypassing Nginx, directly to the forgejo container.
8. Act Runner
Act runner is the official CI pipeline executor from the Gitea/Forgejo team. It runs files from .forgejo/workflows/*.yml, whose format is compatible with GitHub Actions syntax — the vast majority of simple workflows carry over with almost no changes.
Registering a runner with a Forgejo instance happens via a token: Settings → Actions → Runners → New runner in the web interface issues a one-time token, which is passed to the runner container through an environment variable on first start.
There are two task execution modes. Docker-in-Docker isolates the runner completely — tasks run inside a nested Docker daemon, inaccessible to the rest of the host, but it's more complex to set up. The simpler option is to forward the host's own /var/run/docker.sock straight into the runner container: the setup is minimal, but the runner then gets actual access to the host's Docker, under the same risk logic as Traefik in article 3 of this series.
The labels assigned to a runner at registration determine which runs-on: values in workflow files it's able to serve — the same principle as in self-hosted runners for GitHub Actions or GitLab CI.
9. First pipeline
The file .forgejo/workflows/deploy.yml is written in syntax identical to GitHub Actions. The standard flow is the same abstract pipeline from article 4 of the series: trigger on push, checkout the code, build the Docker image, publish it — only now all of this runs on your own runner, not on someone else's cloud CI platform infrastructure.
ℹ️ Detailed syntax: a step-by-step setup of workflow files for Gitea/Forgejo Actions is covered in a separate article of the "CI/CD from Scratch" series devoted specifically to this platform.
10. Forgejo Container Registry
Forgejo has a built-in OCI-compatible container registry, available right at your instance's address — git.example.com — as an alternative to Docker Hub or ghcr.io from article 4. Images are stored alongside the repositories themselves, and authorization uses the same token as git operations — there's no need to set up a separate account for the registry.
This closes the stack into a single whole: Forgejo simultaneously serves as the Git server, the CI platform, and the registry — all three components that in the previous article of the series were spread across different external services now live on one VPS under your full control.
11. Backup and updates
A reliable backup consists of three steps: stop Forgejo, archive the forgejo-data volume (repositories, configuration, LFS objects), and separately take a database dump via pg_dump. Both files should be stored together, marked with a date — restoring from a repository archive and a database dump that aren't synchronized in time can lead to an inconsistent state.
Updating the version follows the pattern familiar from the series: docker compose pull, then docker compose up -d. Forgejo supports rolling database migrations automatically when a new version starts, so in most cases no additional manual steps are needed. The exception is major updates: before those, it's worth checking the CHANGELOG for the specific version, since some major transitions require a separate manual step before starting the new image.
12. Conclusion and series summary
The path of the whole series — from docker run hello-world in the first article to a complete self-hosted stack combining Git, CI, registry, and SSL in a single docker-compose.yml on a single server. Each subsequent article removed one specific limitation of the previous one: manually running containers → a declarative description of the stack → production readiness → automatic deploys → full independence from external platforms.
This entire stack can be recreated on any new VPS in about thirty minutes: git clone the configuration repository with docker-compose.yml and .env, then docker compose up -d — and the platform is up.
The logical continuation for anyone for whom one server is no longer enough is Docker Swarm: the same approach to describing a stack, but distributed across several nodes, with automatic failover if one of them goes down.
📚 Series navigation:
You're reading part 5 of 5, "Gitea / Forgejo in Docker: a complete self-hosted stack" — the final article of the series.
Previous: Part 4. Docker + CI/CD: automatic deployment ←
🚀 A VPS for your own Git + CI + registry
Four services at once — a Git server, a database, a CI runner, and a reverse proxy — is already a noticeable load on CPU and disk, especially during a pipeline run. Hostiserver provides resources sized for exactly this kind of self-hosted stack.
🖥️ Dedicated Servers
- From $90/mo, full control over the hardware for active CI workloads
- No resource neighbors: image builds on the runner don't depend on other people's containers
- 24/7 support: we'll help with networking, the SSH port, and runner registration
💻 Cloud (VPS) Hosting
- From $19.95/mo, KVM isolation, dedicated vCPU and RAM
- NVMe storage: fast git operations, LFS, and Docker image builds right on the runner
- Dedicated IP address: a stable domain for git clones over SSH and for SSL
- Easy to scale: more resources for the runner as pipelines increase
💬 Not sure which option you need?
💬 Write to us and we'll help you decide!
Frequently Asked Questions
- Gitea or Forgejo — which to choose for a new project?
The functional difference is minimal, so the choice more often comes down to the governance model: Forgejo develops as a community-driven project under Codeberg, Gitea under separate leadership. Since the API is fully compatible, this choice isn't irreversible — you can switch from one to the other later without losing data.
- Can an existing Gitea instance be migrated to Forgejo without loss?
Yes, thanks to full API and database structure compatibility, migration in either direction goes through without losing repositories, issues, or settings. In practice it comes down to swapping the Docker image and bringing the stack up again with the same database volume.
- Why does SSH come out externally on port 2222 instead of the standard 22?
Port 22 on the VPS itself is already taken by the system SSH server used to administer the host. 2222 → 22 inside the container is the simplest way to avoid the conflict. If you need exactly the standard port 22 for git clones, there's an alternative via
AuthorizedKeysCommandon the host, but it's more complex to set up.
- Docker-in-Docker or the host docker socket for act runner — which to choose?
The host docker socket is simpler to set up — just forward
/var/run/docker.sock— but it gives the runner actual access to the host's Docker, and therefore indirect root access. Docker-in-Docker is fully isolated but more complex to configure. For a single trusted project on your own server, the host socket is usually an acceptable trade-off; for CI that runs code from external pull requests, the isolated option is the better choice.
- Is a separate Docker registry needed if Docker Hub is already available?
Not required, but Forgejo's built-in registry removes one more external dependency and one more account to authorize — the token is the same one used for git operations. If the goal is exactly full independence from external services, using the built-in registry logically completes that idea.
- How do you back up Forgejo correctly so you don't end up with an inconsistent state?
The key point is consistency in time: the
forgejo-datavolume archive and the database dump viapg_dumpare taken at one point in time, preferably with the service briefly stopped, and stored together with the same date marker. Restoring from an old database dump and a new repository archive (or vice versa) can lead to mismatches between the metadata in the database and the actual state of the Git repositories.
- Is it mandatory to go through the Installation wizard in the browser?
No. If all the needed parameters (database, administrator, domain) are set in advance via environment variables with the
FORGEJO__prefix, andINSTALL_LOCK=trueis also set, the container starts up already ready — convenient for reproducible deployment from configuration, without manually going through the wizard every time.