Why self-host Gitea on a VPS
Gitea uses barely 200 to 300 MB of RAM at idle, making it one of the most efficient Git forges to self-host. On a VPS, you keep full control of your source code: no data is analyzed by a third party, no arbitrary limit on the number of private repositories or collaborators, and no cost that climbs with your team. For an agency or an independent developer, a single VPS can host all client projects with permissions partitioned by organization. Gitea natively includes CI/CD compatible with GitHub Actions workflows (Gitea Actions), a package registry and a web editor, which covers almost the entire development cycle with no external dependency.
Concrete benefits of a self-hosted Gitea
- Minimal memory footprint: runs comfortably on a 2 GB RAM VPS even with several dozen users.
- Private repositories without per-seat billing or imposed limits, unlike SaaS offerings.
- Gitea Actions built in to run your CI/CD pipelines without an external service.
- Package registry (Docker, npm, Composer, Maven) hosted on the same instance.
- Fine-grained authentication: LDAP, OAuth2, personal access tokens and per-user SSH keys.
- Trivial backup: everything fits in one data volume and a database.
Hardware and software prerequisites
Gitea is very frugal. For a small team (up to 20 users), a VPS with 2 vCPU and 2 GB of RAM is more than enough; count on 4 GB if you enable Gitea Actions with local runners that compile code. Plan for at least 20 to 40 GB of SSD storage depending on the size of your repositories. On the software side: Docker Engine and the Docker Compose plugin installed, a domain name (or a subdomain such as git.yourdomain.com) pointing to the VPS IP via an A record, and port 22 reserved for the server's SSH — Gitea will expose its own SSH on another port to avoid the conflict.
Deploy Gitea with Docker and SSL
Prepare the VPS and Docker
Connect via SSH, update the system then install Docker and the Compose plugin. Create a dedicated directory: mkdir -p /opt/gitea && cd /opt/gitea. Also create a data volume that will survive container updates.
Write the docker-compose.yml
Define two services: gitea (image gitea/gitea:latest) and a postgres:16 database. Mount ./gitea:/data for persistence, set USER_UID/USER_GID to 1000, and map Gitea's SSH to host port 2222: "2222:22". Leave the HTTP port 3000 internal; it will be served by the reverse proxy.
Launch the containers
Run docker compose up -d then docker compose logs -f gitea to follow the initialization. Check that the connection to PostgreSQL succeeds before continuing.
Configure the reverse proxy
With Caddy, a single line is enough: git.yourdomain.com { reverse_proxy localhost:3000 }. Caddy automatically obtains and renews the Let's Encrypt certificate. With Nginx, create a server that proxies to http://127.0.0.1:3000 and use certbot --nginx for SSL.
Finalize the web installation
Open https://git.yourdomain.com, complete the wizard by filling in the HTTPS base URL and the database host (db:5432). Set ROOT_URL correctly, otherwise the clone links will be wrong.
Secure repository SSH
Configure your clients to clone via ssh://[email protected]:2222/..., or add a Host block in ~/.ssh/config to hide the port. Disable public sign-up in the admin if the instance is private.
Logging in for the first time
Open the URL: Gitea shows its installation page, with the database section already pre-filled — leave it alone. Expand "Optional Settings > Administrator Account Settings", create your admin account there, then submit.
Enable Gitea Actions right from installation by adding [actions] ENABLED = true to app.ini, then register an act_runner runner in a separate container on the same VPS. Cap its RAM via mem_limit so that a heavy build does not choke Gitea itself. For heavy pipelines (compilation, integration tests), dedicate a second VPS to the runner instead and connect it with the registration token — you keep the forge responsive even during builds.
Gitea or Forgejo in 2026: which one to choose?
| Criterion | Gitea | Forgejo v16 |
|---|---|---|
| Governance | Commercial company (Gitea Ltd) | Non-profit association (Codeberg e.V.) |
| License | MIT | MIT — 100% free software, no enterprise edition |
| Recommended by Awesome-Selfhosted | No (removed in 2022) | Yes — default recommendation since 2024 |
| ActivityPub federation | Not planned | Progressively rolling out since Forgejo v7 |
| Gitea API compatibility | Reference | Compatible: same API, same webhooks, same data format |
| Security — public audits | Rare | Code audits published by the Codeberg community |
| Community roadmap | Driven by Gitea Ltd | Open governance, public RFCs, contributor voting |
| Migration from Gitea | N/A | No data loss: same database schema and same volume format |
Migrate from Gitea to Forgejo
Back up your Gitea instance
Before anything, create a full dump: docker exec -u git gitea gitea admin dump -c /data/gitea/conf/app.ini. Retrieve the generated archive outside the container and verify that the ./gitea volume is included in your VPS snapshot.
Check version compatibility
Forgejo v16 supports migration from Gitea 1.20 and later. If your instance is running an older version, first upgrade to Gitea 1.21 or 1.22 via docker compose pull && docker compose up -d, then verify there are no errors in the logs before proceeding.
Replace the Docker image
In your docker-compose.yml, replace image: gitea/gitea:latest with image: codeberg.org/forgejo/forgejo:latest. The data volume (./gitea:/data) and the PostgreSQL database remain unchanged — Forgejo reads the same schema and the same app.ini.
Restart and let it migrate
Run docker compose pull && docker compose up -d. Forgejo automatically applies the necessary schema migrations at startup. Follow docker compose logs -f forgejo until the message indicating that the HTTP server is listening on port 3000.
Regenerate SSH keys
As with any upgrade that changes the binary referenced in authorized_keys, regenerate the entries: docker exec -u git forgejo forgejo admin regenerate keys. Then verify that a git clone over SSH succeeds from a client machine.
Test and validate
Open the web interface, check repositories, webhooks, and Forgejo Actions runners. act_runner runners registered under Gitea are compatible — reconnect them to the new registration token if the key has changed.
Forgejo v16: key highlights (July 2026)
Forgejo v16.0.0 brings ActivityPub federation to general availability for issues and pull requests: an issue opened on one Forgejo instance can receive comments from users on another instance, without a shared account. The release also includes an instance-level secrets manager (AES-256 encryption at rest), a new code search engine based on the Bleve v2 index with regex support, and significant improvements to the background task scheduler. On the security front, API tokens are now hashed in the database (bcrypt) instead of stored in plaintext — an automatic migration at startup converts existing tokens.
Upgrading to Gitea 1.27 without losing SSH access
Version 1.27 changes the internal path of the gitea binary referenced inside authorized_keys. During the upgrade, existing entries still point to the old path: any clone or push over SSH silently fails with Permission denied (publickey), without any server-side error message pointing to the upgrade. The Gitea service responds normally over HTTPS, which masks the problem. The cause is not the user's key but the command wrapper that Gitea injects into authorized_keys. After every upgrade to 1.27 (or to any version that changes this path), run inside the container: docker exec -u git gitea gitea admin regenerate keys. The command rewrites all authorized_keys entries with the correct binary path. Verify that a git clone over SSH succeeds before declaring the upgrade complete.
Troubleshooting: common errors during migration
Permission denied (publickey) after migration to Forgejo. The binary referenced in authorized_keys has changed. Regenerate the entries with docker exec -u git forgejo forgejo admin regenerate keys.
Database migration failed: column already exists. The database received a partial migration (stopped mid-upgrade). Restore the full dump created in step 1, start from a clean volume, then restart.
Webhooks no longer trigger after migration. Forgejo v16 tightens webhook URL validation: URLs pointing to localhost or RFC-1918 ranges are blocked by default. Enable the ALLOWED_HOST_LIST option in the [webhook] section of app.ini if your runners are on the same private network.
act_runner runners report token invalid. Forgejo v16 hashes runner tokens in the database. Delete the old runner in the admin interface, re-register it with act_runner register and the newly generated token.
JWT_SECRET_URI and JWT_SECRET must not coexist in app.ini. If both keys are present, Gitea or Forgejo loads one or the other depending on read order, silently invalidating all OAuth2 and Actions tokens issued before the upgrade. Users see intermittent authentication errors with no message pointing to app.ini as the cause. Choose one mechanism: JWT_SECRET (plaintext value) or JWT_SECRET_URI (path to a secret file), then remove the other. Restart the container after the change.
The official documentation
For advanced configuration and options specific to the tool, refer to the official Gitea documentation or the Forgejo documentation. This guide covers going live on a VPS and migration; the vendor docs remain the reference for fine-tuning and specific use cases.