Self-Hosting Forgejo on Windows Server Hyper-V Step-by-Step

I recently stood up a self-hosted Forgejo instance to mirror my GitHub repositories. The goal: own my code infrastructure, reduce dependency on a single platform, and have a private forge for projects that don’t belong on public GitHub. If you’ve read the earlier posts in this series about open-source sovereignty and where your code should live besides GitHub, this is the “put your code where your mouth is” follow-up.

This guide covers the entire process, from creating the Hyper-V VM through automated backups. The host is Windows Server with Hyper-V, the guest is Debian 13, and everything runs in Docker containers.

Woman systems administrator at a workstation with Forgejo dashboard on screen and a server rack in the background

What You’ll Need

  • A Windows Server host with Hyper-V enabled
  • Debian 13 (Trixie) netinst ISO from debian.org
  • A domain name pointed at your public IP
  • A reverse proxy with TLS (this guide uses SWAG, but nginx or Caddy work too)
  • A network share for backups (optional but recommended)

Step 1: Create the Hyper-V VM

In Hyper-V Manager, create a new VM with these settings:

  • Generation: 2 (required for UEFI boot and Secure Boot)
  • Memory: Dynamic, startup 8192 MB, minimum 2048 MB, maximum 16384 MB
  • Disk: Dynamic VHDX, sized to your needs (I used 2 TB; the VHDX only consumes physical space for data actually written)
  • Network: Connected to your external virtual switch
  • ISO: Mount the Debian 13 netinst ISO

A few settings to change before first boot:

  • Automatic Stop Action: Set to Shut Down, not Save. Hibernating a Linux VM with Docker containers can cause stale network connections and PostgreSQL time-jump issues on resume.[1]
  • Automatic Start Action: Set to auto-start so the VM comes back after host reboots.

Step 2: Install Debian 13 (Trixie)

Boot the VM and select Install (not Expert Install). The standard installer handles everything you need.

Why Trixie instead of Bookworm? Debian 13 ships with kernel 6.12, which has full Hyper-V support built in: dynamic memory, TRIM, vRSS, Hyper-V sockets. Bookworm’s stock kernel (6.1) needs a backports kernel for these features. Since Trixie is now the current stable release with five years of support ahead, there’s no reason to start with an older version.[2]

Key choices during installation:

  • Partitioning: Guided, use entire disk, all files in one partition, ext4. A single partition is simplest for a Docker host. Separate /var and /home partitions risk one filling up while the other sits empty.
  • Reserved blocks: Reduce from the default 5% to 0.5% or 1%. On a large disk, 5% reserves a substantial amount of space. Note that reserved blocks only prevent non-root processes from filling the disk; Docker’s daemon runs as root and ignores this limit entirely.[3]
  • Root account: Leave it locked. Debian automatically installs sudo and adds your user to the sudo group.
  • Software selection: Select only SSH server and standard system utilities. No desktop environment, no web server. All web serving happens inside Docker containers.

Step 3: Post-Installation Networking

After first boot, you may find that the network isn’t fully functional. If you installed from the DVD ISO, your apt sources will still point at the local DVD media.

Fix apt sources

Comment out any deb cdrom: lines and add:

Then run sudo apt update.

Configure a static IP

For a server that will host services, a static IP is preferable. Edit /etc/network/interfaces:

Apply with sudo ifdown eth0 && sudo ifup eth0.

Note: the dns-nameservers directive requires the resolvconf package. If DNS isn’t working after setting this, either install resolvconf or edit /etc/resolv.conf directly.

Install Hyper-V guest daemons

This enables TRIM (deleted data in the VM reclaims space on the host VHDX), key-value pair exchange with the Hyper-V host, and VSS snapshot support.

Suppress VSOCK console noise

You may see repeated systemd-ssh-generator: Failed to query local AF_VSOCK CID messages on the console. This is harmless; Hyper-V doesn’t use VSOCK for SSH. Suppress it:

Step 4: Install Docker

Install Docker Engine from the official Docker repository, not the Debian-packaged version (which lags behind):

Verify it works:

Add your user to the docker group so you can run Docker commands without sudo:

Log out and back in for the group change to take effect.

Configure container DNS

Docker containers use an internal DNS resolver (127.0.0.1) by default. If your containers need to resolve hostnames on your local network or the internet, configure Docker to use your DNS servers:

Then sudo systemctl restart docker. Without this, containers that need to reach external services (like GitHub for mirroring) will fail with DNS resolution errors.[4]

Step 5: Deploy Forgejo with Docker Compose

Create the directory structure and compose file:

Generate a strong database password:

Create docker-compose.yml:

A few important notes about this compose file:

  • SSH is disabled. All git operations go over HTTPS through the reverse proxy. This eliminates an entire attack surface. You can always enable it later if you want.
  • Use the key-value syntax for environment variables (not the list syntax with dashes). If you must use the list syntax, wrap values with special characters in double quotes, not single quotes. Single quotes in YAML are literal: 'mypassword' stores the value including the quote characters.[5]
  • The PostgreSQL password must match in both the Forgejo and PostgreSQL sections. PostgreSQL only reads POSTGRES_PASSWORD during first-time initialization. If you need to change it later, you must delete the ./postgres directory and let it reinitialize.[6]

Start it up:

If you want a separate PostgreSQL schema

If you prefer Forgejo’s tables in their own schema rather than public, create it before running the initial setup:

Then specify forgejo_app as the schema on Forgejo’s onboarding page.

Initial configuration

Navigate to http://your-server-ip:3000 and complete the setup form. Recommended settings for a personal instance:

  • Password hash algorithm: Argon2 (memory-hard, resistant to GPU brute-force)
  • Self-registration: Disabled
  • OpenID sign-in: Disabled
  • Gravatar: Enabled (your choice; minimal privacy impact on a single-user instance)

The ROOT_URL redirect loop

If you set ROOT_URL to an HTTPS address but access the instance via HTTP, Forgejo enters a redirect loop. During initial setup (before your reverse proxy is ready), temporarily set ROOT_URL to the HTTP address in your compose file. Change it back to HTTPS after the reverse proxy is working.

Environment variables in the compose file override app.ini at runtime, so you must change ROOT_URL in the compose file, not in app.ini.[7]

Step 6: Reverse Proxy with TLS

This guide uses SWAG (Secure Web Application Gateway), but any reverse proxy works. The key requirements:

  • Proxy https://code.example.com to http://your-forgejo-ip:3000
  • TLS certificate from Let’s Encrypt
  • WebSocket support (Forgejo uses WebSockets for live UI updates)

For SWAG, copy the Gitea subdomain sample config and customize it:

Then add your domain to SWAG’s EXTRA_DOMAINS environment variable and restart SWAG to request the TLS certificate.

Checklist before enabling the reverse proxy:

  • DNS record (A or CNAME) pointing code.example.com to your public IP
  • Firewall rule allowing port 3000 from the reverse proxy to the Forgejo VM
  • If the reverse proxy is on a different subnet, a static route on the Forgejo VM

Once the reverse proxy is working, update ROOT_URL in your compose file to the HTTPS address and docker compose down && docker compose up -d.

Step 7: Mirror Your GitHub Repositories

Forgejo can pull-mirror repositories from GitHub, keeping a local copy that syncs automatically (every 8 hours by default).

First, configure the migration allow-list. Edit app.ini inside the Forgejo container:

Add:

Restart: docker compose restart server

The allow-list must include *.github.com because Forgejo’s migration feature calls api.github.com in addition to cloning from github.com. Without the wildcard, migrations fail with a “disallowed hosts” error.[8]

You can create mirrors through the web UI (New Migration), or script it with the Forgejo REST API:

If a migration fails partway through, it may create an empty repository. Delete it before retrying; otherwise the retry fails with “repository already exists.”

What happens when the source disappears?

Pull mirrors sync by fetching from the remote on a schedule. This raises a natural question: if something happens to the GitHub copy, does the damage propagate to your mirror? It depends on what “something” means.

The source repo is deleted entirely (DMCA takedown, account suspension, or you delete it yourself). The next mirror sync fails because the remote returns a 404. Your Forgejo copy stays intact with whatever it last fetched. You just see sync errors in the logs.

GitHub force-pushes to remove specific commits (surgical content scrubbing). This is the scenario to watch. A pull mirror updates its local refs to match the remote. If branch history is rewritten upstream, your mirror’s branches move to match on the next sync. The removed commits briefly survive as dangling objects in Forgejo’s git storage, but they’re cleaned up when garbage collection runs.

Specific branches or tags are removed upstream. With pruning enabled (the default for mirrors), those refs are deleted from your Forgejo copy on the next sync.

The takeaway: a full takedown actually preserves your local copy, but surgical history rewrites can propagate. This is why the daily backup in Step 9 matters: even if a mirror sync scrubs something, yesterday’s backup still has it.

The stronger mitigation is making Forgejo your primary and converting the pull mirrors to push mirrors. When you commit and push to Forgejo, it pushes outbound to GitHub on your behalf. The mirror direction is now one-way outbound: nothing on GitHub can propagate back to Forgejo. A DMCA takedown on GitHub removes the GitHub copy, but your Forgejo instance is unaffected because it never pulls from GitHub. You control the source of truth.

This guide sets things up with GitHub as primary because most people are starting from existing GitHub repositories. Once you’re comfortable with the Forgejo workflow, converting to push mirrors is a configuration change in the Forgejo web UI (Repository Settings, Mirror Settings).

Step 8: Monitoring

Docker’s daemon runs as root, so ext4’s reserved blocks won’t prevent it from filling the disk. Set up active monitoring instead.

Disk usage alerts

Create /usr/local/bin/check-disk.sh:

Create a systemd timer to run it daily at 6 AM:

Docker prune

Schedule a weekly cleanup of unused images, containers, and volumes:

Step 9: Automated Backups

The backup strategy has two layers: application-level backups for daily granular recovery, and Hyper-V VM-level backup for full disaster recovery. This is the implementation of the approach described in Setting Up Your Three-Layer Backup.

Network backup to a Windows share

If your backup storage is a Windows server with a RAID array (common in Windows-centric environments), mount the share via SMB:

Create a credentials file:

Add the mount to /etc/fstab:

The vers=3.0 option forces SMB 3.0 as the minimum protocol version. Without it, the Linux CIFS client negotiates the highest version both sides support, which is usually fine, but being explicit prevents a silent fallback to an older version. SMB 1.0 in particular is severely insecure (no encryption, vulnerable to relay attacks, and was the protocol exploited by WannaCry in 2017) and should never be used. The _netdev option tells systemd to wait for network before mounting.

A note on credentials file security

The credentials file above stores an Active Directory password in plaintext. The chmod 600 restricts it to root, but any process running as root can still read it, and it survives in filesystem backups (including the very backups it enables). For a dedicated, single-purpose VM on a private network, this is a common and accepted pattern, but you should understand the trade-offs.

Use a dedicated service account with permissions limited to a single share. If the credentials are ever exposed, the blast radius is one network share, not your entire domain. Never reuse an administrator account or a personal account for automated mounts.

Consider Kerberos authentication instead of a password file. Join the Linux VM to your Active Directory domain, obtain a machine keytab, and mount the share with sec=krb5. This eliminates the plaintext password entirely; authentication is handled by the Kerberos ticket system, and keytab renewal is automatic. The trade-off is added complexity: domain-joining a Linux box requires packages like sssd and realmd, and the Kerberos configuration needs to match your domain topology.

Consider mounting on-demand rather than persistently. Instead of an /etc/fstab entry that keeps the share mounted at all times, the backup systemd service can mount the share, run the backup, and unmount when finished. This reduces the window during which credentials are actively in use and limits exposure if the VM is compromised while idle.

The approach in this guide (dedicated service account, restricted permissions, persistent mount) prioritizes simplicity. If your environment demands tighter controls, Kerberos with on-demand mounting is the path to take.

Backup script

The backup script reads its configuration from a JSON file, keeping server names and paths out of the script itself. Create /etc/forgejo-backup/config.json:

Save the following as /usr/local/bin/forgejo-backup.sh:

Test it manually: sudo forgejo-backup.sh

Then create a systemd timer to run daily at 2 AM:

Step 10: Keeping Everything Updated

A self-hosted server that never gets patched is a liability. There are three layers to keep current: the OS, the Docker images, and Docker Engine itself.

Automatic OS security patches

Debian’s minimal install does not enable automatic updates. Install unattended-upgrades to apply security patches automatically:

Select “Yes” when prompted. This enables daily automatic installation of security updates only; it will not upgrade you to a new Debian release or install non-security package updates. Logs go to /var/log/unattended-upgrades/.

Updating Docker images

Docker never auto-updates containers. The images in your docker-compose.yml (forgejo/forgejo:15 and postgres:17) stay at whatever version was pulled until you explicitly update them. To pull the latest patches within your pinned major versions:

This downloads the latest 15.x and 17.x images and recreates the containers. Your data volumes persist across container rebuilds; no data is lost. Check the Forgejo release notes before updating to be aware of any breaking changes.

Build this into a routine: check for image updates monthly, or subscribe to the Forgejo release feed. You could automate it with a systemd timer, but for a single-server setup, a manual pull with a quick review of the release notes is the safer approach.

Updating Docker Engine

Docker Engine itself is installed from Docker’s apt repository and is covered by apt upgrade, but unattended-upgrades only applies Debian security repo updates by default. To update Docker Engine:

This picks up new Docker Engine releases from the Docker repo you added in Step 4. A Docker Engine upgrade restarts the daemon, which briefly restarts your containers (they come back automatically because of the restart: always policy in the compose file).

Step 11: Hyper-V Tuning

A few final Hyper-V settings worth adjusting for a production Linux VM:

  • Static MAC address: If you use Hyper-V Replica, set the MAC to static so it doesn’t change on failover (which would break DHCP reservations).
  • CPU count: Increase from the default 1 to 4 or more. Forgejo and PostgreSQL both benefit from multiple cores during git operations.
  • Dynamic memory: With hyperv-daemons installed, the VM negotiates memory with the host. Set a comfortable startup value (8 GB) and let it scale between your minimum and maximum.

The Result

After about two hours of setup, you’ll have:

  • A self-hosted Forgejo instance with TLS, accessible from the internet
  • PostgreSQL-backed storage with automated daily backups
  • Pull mirrors of your GitHub repositories, syncing every 8 hours
  • Disk monitoring, automated Docker cleanup, and a clean systemd timer schedule:
    • 2 AM: Forgejo backup (database + data to network share)
    • 3 AM: Docker prune (cleanup old images and volumes)
    • 6 AM: Disk usage check (syslog alerts at 80% and 90%)
  • Automatic OS security patching via unattended-upgrades

The Forgejo web UI is polished, responsive, and feature-complete. Issues, pull requests, project boards, a package registry, CI/CD via Forgejo Actions, and a built-in wiki are all included. For an open-source project maintained by a community of volunteers, it’s genuinely impressive.

When you’re ready to make Forgejo your primary forge, convert the pull mirrors to push mirrors (Forgejo pushes to GitHub after each local commit). That way GitHub becomes the backup, and your self-hosted instance is the source of truth.

Footnotes

[1] The “Save” automatic stop action hibernates the VM to disk. When it resumes, Docker containers may have stale network connections and PostgreSQL can get confused by the time jump. A clean shutdown (2-3 seconds on a headless Debian server) followed by a fresh boot is more reliable.

[2] Debian 12 (Bookworm) is still supported but is now in the archive at cdimage.debian.org/cdimage/archive/. Its stock kernel (6.1) supports basic Hyper-V operation, but dynamic memory and TRIM require installing a backports kernel. If you specifically need Bookworm, the backports setup is a one-time apt install -t bookworm-backports linux-image-amd64.

[3] Reserved blocks create a threshold: non-root processes see the disk as full while root can still write. The idea is to ensure you can always SSH in and clean up. Since Docker’s daemon runs as root, it will happily consume the reserved space too, making the protection ineffective for Docker-heavy workloads. Active disk monitoring is more reliable.

[4] Docker’s internal DNS resolver (127.0.0.1) handles container-to-container name resolution within a compose network. But it doesn’t automatically forward to host DNS for external lookups. If your containers need to reach the internet or local network resources, you must configure external DNS servers in /etc/docker/daemon.json.

[5] In Docker Compose YAML, the list-style environment syntax (- VARIABLE='value') stores single quotes as literal characters in the value. The password 'abc123' is stored as seven characters, including the quotes. Use the key-value syntax (VARIABLE: "value") or double quotes in the list syntax instead.

[6] The official PostgreSQL Docker image processes POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB only when the data directory is empty (first-time initialization). If you change these values after the first run, the data directory still has the old credentials. Delete ./postgres and docker compose up -d to reinitialize.

[7] Forgejo environment variables (FORGEJO__section__KEY) override the corresponding app.ini values at runtime. They don’t rewrite the file; they take priority in memory. Settings in app.ini that have no corresponding environment variable are preserved and used as-is.

[8] Forgejo’s migration feature contacts the GitHub API (api.github.com) to gather repository metadata before cloning. The ALLOWED_DOMAINS setting must include this hostname, not just github.com. Using *.github.com covers all GitHub subdomains including codeload.github.com (used for archive downloads) and objects.githubusercontent.com (used for LFS objects).

Have questions or war stories about self-hosting? Find me on Bluesky or LinkedIn.