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.

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
/varand/homepartitions 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
sudoand adds your user to thesudogroup. - 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
|
1 |
sudo vi /etc/apt/sources.list |
Comment out any deb cdrom: lines and add:
|
1 2 3 |
deb http://deb.debian.org/debian trixie main contrib non-free-firmware deb http://deb.debian.org/debian-security trixie-security main contrib non-free-firmware deb http://deb.debian.org/debian trixie-updates main contrib non-free-firmware |
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:
|
1 2 3 4 5 6 |
auto eth0 iface eth0 inet static address 192.168.1.50 netmask 255.255.255.0 gateway 192.168.1.1 dns-nameservers 192.168.1.1 192.168.1.2 |
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
|
1 |
sudo apt install -y hyperv-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:
|
1 |
sudo systemctl mask systemd-ssh-generator |
Step 4: Install Docker
Install Docker Engine from the official Docker repository, not the Debian-packaged version (which lags behind):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg \ | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) \ signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/debian trixie stable" \ | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin |
Verify it works:
|
1 |
sudo docker run hello-world |
Add your user to the docker group so you can run Docker commands without sudo:
|
1 |
sudo usermod -aG docker $USER |
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:
|
1 |
sudo vi /etc/docker/daemon.json |
|
1 2 3 |
{ "dns": ["192.168.1.1", "192.168.1.2"] } |
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:
|
1 2 |
sudo mkdir -p /opt/forgejo cd /opt/forgejo |
Generate a strong database password:
|
1 |
openssl rand -hex 24 |
Create docker-compose.yml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
networks: forgejo: external: false services: server: image: codeberg.org/forgejo/forgejo:15 container_name: forgejo environment: USER_UID: 1000 USER_GID: 1000 FORGEJO__database__DB_TYPE: "postgres" FORGEJO__database__HOST: "db:5432" FORGEJO__database__NAME: "forgejo" FORGEJO__database__USER: "forgejo" FORGEJO__database__PASSWD: "your-generated-password" FORGEJO__server__ROOT_URL: "https://code.example.com/" FORGEJO__server__DISABLE_SSH: "true" restart: always networks: - forgejo volumes: - ./forgejo:/data - /etc/localtime:/etc/localtime:ro ports: - "3000:3000" depends_on: - db db: image: postgres:17 container_name: forgejo-db restart: always environment: POSTGRES_USER: "forgejo" POSTGRES_PASSWORD: "your-generated-password" POSTGRES_DB: "forgejo" networks: - forgejo volumes: - ./postgres:/var/lib/postgresql/data |
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_PASSWORDduring first-time initialization. If you need to change it later, you must delete the./postgresdirectory and let it reinitialize.[6]
Start it up:
|
1 |
docker compose up -d |
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:
|
1 2 |
docker exec -it forgejo-db psql -U forgejo -d forgejo \ -c "CREATE SCHEMA IF NOT EXISTS forgejo_app;" |
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.comtohttp://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:
|
1 2 3 4 5 |
server_name code.example.com; set $upstream_app 192.168.1.50; set $upstream_port 3000; set $upstream_proto http; |
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.comto 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:
|
1 |
docker exec -it forgejo vi /data/gitea/conf/app.ini |
Add:
|
1 2 |
[migrations] ALLOWED_DOMAINS = github.com,*.github.com,*.githubusercontent.com |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
$token = "your-forgejo-api-token" $headers = @{ "Authorization" = "token $token" "Content-Type" = "application/json" } $repos = @("repo-one", "repo-two", "repo-three") foreach ($repo in $repos) { $body = @{ clone_addr = "https://github.com/YourUser/$repo.git" repo_name = $repo repo_owner = "your-forgejo-user" service = "github" mirror = $true private = $false } | ConvertTo-Json Invoke-RestMethod -Uri "https://code.example.com/api/v1/repos/migrate" ` -Method Post -Headers $headers -Body $body Start-Sleep -Seconds 2 } |
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:
|
1 2 3 4 5 6 7 |
#!/bin/bash USAGE=$(df / --output=pcent | tail -1 | tr -d ' %') if [ "$USAGE" -ge 90 ]; then logger -p local0.crit "DISK CRITICAL: Root filesystem at ${USAGE}%" elif [ "$USAGE" -ge 80 ]; then logger -p local0.warning "DISK WARNING: Root filesystem at ${USAGE}%" fi |
|
1 |
sudo chmod +x /usr/local/bin/check-disk.sh |
Create a systemd timer to run it daily at 6 AM:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# /etc/systemd/system/check-disk.service [Unit] Description=Check disk usage [Service] Type=oneshot ExecStart=/usr/local/bin/check-disk.sh # /etc/systemd/system/check-disk.timer [Unit] Description=Daily disk usage check [Timer] OnCalendar=*-*-* 06:00 Persistent=true [Install] WantedBy=timers.target |
|
1 2 |
sudo systemctl daemon-reload sudo systemctl enable --now check-disk.timer |
Docker prune
Schedule a weekly cleanup of unused images, containers, and volumes:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# /etc/systemd/system/docker-prune.service [Unit] Description=Docker system prune Requires=docker.service After=docker.service [Service] Type=oneshot ExecStart=/usr/bin/docker system prune -af --volumes # /etc/systemd/system/docker-prune.timer [Unit] Description=Weekly Docker system prune [Timer] OnCalendar=Sun 03:00 Persistent=true [Install] WantedBy=timers.target |
|
1 2 |
sudo systemctl daemon-reload sudo systemctl enable --now docker-prune.timer |
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:
|
1 2 3 |
sudo apt install -y cifs-utils jq sudo mkdir -p /mnt/backup sudo mkdir -p /etc/forgejo-backup |
Create a credentials file:
|
1 2 3 4 |
# /etc/forgejo-backup/smb-credentials username=backup-service-account password=your-password domain=YOURDOMAIN |
|
1 |
sudo chmod 600 /etc/forgejo-backup/smb-credentials |
Add the mount to /etc/fstab:
|
1 |
//backup-server/forgejo-backups /mnt/backup cifs credentials=/etc/forgejo-backup/smb-credentials,vers=3.0,uid=1000,gid=1000,file_mode=0660,dir_mode=0770,_netdev 0 0 |
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:
|
1 2 3 4 5 6 7 8 9 |
{ "backup_dir": "/mnt/backup/forgejo", "pg_container": "forgejo-db", "pg_user": "forgejo", "pg_database": "forgejo", "forgejo_data": "/opt/forgejo/forgejo", "retention_days": 30, "compose_dir": "/opt/forgejo" } |
Save the following as /usr/local/bin/forgejo-backup.sh:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
#!/bin/bash set -euo pipefail DEFAULT_CONFIG="/etc/forgejo-backup/config.json" TIMESTAMP=$(date +%Y-%m-%dT%H%M) LOG_TAG="forgejo-backup" log_info() { logger -t "$LOG_TAG" -p local0.info "$1"; echo "[INFO] $1"; } log_error() { logger -t "$LOG_TAG" -p local0.err "$1"; echo "[ERROR] $1" >&2; } CONFIG_PATH="${1:-$DEFAULT_CONFIG}" if [ ! -f "$CONFIG_PATH" ]; then log_error "Config file not found: $CONFIG_PATH" exit 1 fi BACKUP_DIR=$(jq -r '.backup_dir' "$CONFIG_PATH") PG_CONTAINER=$(jq -r '.pg_container' "$CONFIG_PATH") PG_USER=$(jq -r '.pg_user' "$CONFIG_PATH") PG_DATABASE=$(jq -r '.pg_database' "$CONFIG_PATH") FORGEJO_DATA=$(jq -r '.forgejo_data' "$CONFIG_PATH") RETENTION_DAYS=$(jq -r '.retention_days' "$CONFIG_PATH") COMPOSE_DIR=$(jq -r '.compose_dir // empty' "$CONFIG_PATH") DAILY_DIR="$BACKUP_DIR/$TIMESTAMP" mkdir -p "$DAILY_DIR" log_info "Starting Forgejo backup to $DAILY_DIR" # PostgreSQL dump log_info "Dumping PostgreSQL database ($PG_DATABASE)..." if docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" -d "$PG_DATABASE" \ --clean --if-exists > "$DAILY_DIR/database.sql" 2>/dev/null; then gzip "$DAILY_DIR/database.sql" DB_SIZE=$(du -h "$DAILY_DIR/database.sql.gz" | cut -f1) log_info "Database dump complete ($DB_SIZE compressed)" else log_error "Database dump failed" rm -f "$DAILY_DIR/database.sql" exit 1 fi # Forgejo data directory log_info "Archiving Forgejo data directory ($FORGEJO_DATA)..." if tar czf "$DAILY_DIR/forgejo-data.tar.gz" \ -C "$(dirname "$FORGEJO_DATA")" "$(basename "$FORGEJO_DATA")" \ 2>/dev/null; then DATA_SIZE=$(du -h "$DAILY_DIR/forgejo-data.tar.gz" | cut -f1) log_info "Data archive complete ($DATA_SIZE compressed)" else log_error "Data archive failed" exit 1 fi # Docker Compose file if [ -n "$COMPOSE_DIR" ] && [ -f "$COMPOSE_DIR/docker-compose.yml" ]; then cp "$COMPOSE_DIR/docker-compose.yml" "$DAILY_DIR/docker-compose.yml" log_info "Copied docker-compose.yml" fi # Forgejo app.ini if docker exec forgejo cat /data/gitea/conf/app.ini \ > "$DAILY_DIR/app.ini" 2>/dev/null; then log_info "Copied app.ini" fi # Retention cleanup if [ "$RETENTION_DAYS" -gt 0 ] 2>/dev/null; then DELETED=$(find "$BACKUP_DIR" -maxdepth 1 -type d \ -mtime +"$RETENTION_DAYS" -not -path "$BACKUP_DIR" | wc -l) if [ "$DELETED" -gt 0 ]; then find "$BACKUP_DIR" -maxdepth 1 -type d \ -mtime +"$RETENTION_DAYS" -not -path "$BACKUP_DIR" \ -exec rm -rf {} + log_info "Deleted $DELETED backup(s) older than $RETENTION_DAYS days" fi fi TOTAL_SIZE=$(du -sh "$DAILY_DIR" | cut -f1) log_info "Backup complete: $DAILY_DIR ($TOTAL_SIZE total)" |
|
1 |
sudo chmod +x /usr/local/bin/forgejo-backup.sh |
Test it manually: sudo forgejo-backup.sh
Then create a systemd timer to run daily at 2 AM:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# /etc/systemd/system/forgejo-backup.service [Unit] Description=Forgejo backup Requires=mnt-backup.mount After=mnt-backup.mount docker.service [Service] Type=oneshot ExecStart=/usr/local/bin/forgejo-backup.sh # /etc/systemd/system/forgejo-backup.timer [Unit] Description=Daily Forgejo backup [Timer] OnCalendar=*-*-* 02:00 Persistent=true [Install] WantedBy=timers.target |
|
1 2 |
sudo systemctl daemon-reload sudo systemctl enable --now forgejo-backup.timer |
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:
|
1 2 |
sudo apt install -y unattended-upgrades sudo dpkg-reconfigure -plow unattended-upgrades |
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:
|
1 2 3 |
cd /opt/forgejo docker compose pull docker compose up -d |
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:
|
1 |
sudo apt update && sudo apt upgrade |
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-daemonsinstalled, 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.