Per-repository geofenced Git server via Forgejo
  • Python 73.1%
  • Shell 18.7%
  • Dockerfile 8.2%
Find a file
Albert Armea 63bcd20336
Merge pull request #2 from aarmea/feat/fdroid-repo
fix(fdroid): re-publish when a release gains assets after first sync
2026-07-29 23:11:41 -04:00
config fix(fdroid): generate the keystore password instead of demanding one 2026-07-26 22:14:27 -04:00
fdroid fix(fdroid): re-publish when a release gains assets after first sync 2026-07-30 03:10:49 +00:00
geoblock_watcher Increase HTTP request size limit 2026-05-03 02:32:53 +00:00
nginx feat(fdroid): publish an F-Droid repository from Forgejo releases 2026-07-26 20:20:40 -04:00
.env.example fix(fdroid): generate the keystore password instead of demanding one 2026-07-26 22:14:27 -04:00
.gitignore feat(fdroid): publish an F-Droid repository from Forgejo releases 2026-07-26 20:20:40 -04:00
bootstrap_certs.sh Fix certbot initialization 2026-03-21 21:51:14 +00:00
bootstrap_fdroid_key.sh fix(fdroid): generate the keystore password instead of demanding one 2026-07-26 22:14:27 -04:00
docker-compose.yml feat(fdroid): publish an F-Droid repository from Forgejo releases 2026-07-26 20:20:40 -04:00
LICENSE.md MIT license 2026-03-21 20:02:51 -04:00
README.md fix(fdroid): generate the keystore password instead of demanding one 2026-07-26 22:14:27 -04:00

tl;dr: Not for use in California, but for code

fahrengit-451

This repository provides Git hosting for open source and source-available projects with built-in access control by locale.

Specifically, it provides the following, all running on one machine/VPS:

  • Forgejo — lightweight, Gitea-compatible Git hosting
  • nginx — reverse proxy with TLS termination and GeoIP2 blocking
  • MaxMind GeoLite2 — IP → country + state/province database (auto-updated)
  • geoblock_watcher — watches config/geo_rules.yml and hot-reloads nginx when rules change
  • Certbot — automatic Let's Encrypt certificate renewal
  • fdroid (optional) — publishes an F-Droid repository from the .apk assets attached to your Forgejo releases, so Android apps hosted here can be installed and updated from a phone

Wait, why?

You may want to publish an open source project that, while legal in your locale, does not comply with all laws somewhere else.

This setup allows you to publish your project without compromising its goals by simply disallowing access where those goals and the law conflict.

This was the case for me with shepherd-launcher, where implementing the OS-level age verification required in California (where GitHub is headquartered), Brazil, and potentially elsewhere would compromise the project's stated goals of parental autonomy and user privacy.

I am not a laywer and this is not legal advice.


Directory Layout

.
├── docker-compose.yml
├── .env.example                 ← copy to .env and fill in
├── bootstrap_certs.sh           ← run once before first `docker compose up`
├── bootstrap_fdroid_key.sh      ← run once before enabling the F-Droid service
├── config/
│   ├── geoblock_pages/          ← place HTML "blocked" messages here
│   ├── geo_rules.yml.example    ← copy to geo_rules.yml and edit to configure geo-blocking
│   └── fdroid.yml.example       ← copy to fdroid.yml to publish an F-Droid repo
├── fdroid/
│   ├── Dockerfile
│   └── sync.py                  ← polls Forgejo releases, regenerates the repo
├── nginx/
│   ├── Dockerfile               ← builds nginx + GeoIP2 dynamic module
│   ├── nginx.conf               ← main nginx config (loads GeoIP2 module)
│   ├── conf.d/
│   │   └── git.conf             ← virtual host (HTTP→HTTPS redirect + proxy)
│   └── geoblock/                ← rendered by geoblock_watcher at runtime
│       ├── repo_maps.conf
│       ├── repo_vars.conf
│       └── repo_locations.conf
└── geoblock_watcher/
    ├── Dockerfile
    └── watcher.py

Prerequisites

Requirement Notes
A server or VPS located somewhere your project can legally be hosted If unsure, check with an attorney
Docker Engine ≥ 26 + Compose v2 docker compose version
A public domain name DNS A record → your VPS IP
Ports 80 and 443 open Firewall / security group
MaxMind account Free — sign up here
openssl on the host Used by bootstrap_certs.sh for the dummy cert

Quick Start

1. Configure environment

cp config/geo_rules.yml.example config/geo_rules.yml
cp .env.example .env
$EDITOR .env          # fill in DOMAIN, MAXMIND_*, LETSENCRYPT_EMAIL

.env variables:

Variable Description
DOMAIN Your public domain, e.g. git.example.com
LETSENCRYPT_EMAIL Email for Let's Encrypt expiry notices
MAXMIND_ACCOUNT_ID From your MaxMind account portal
MAXMIND_LICENSE_KEY From your MaxMind account portal
DISABLE_REGISTRATION Set true after creating your admin account

2. Bootstrap TLS certificates (first run only)

chmod +x bootstrap_certs.sh
./bootstrap_certs.sh

This will:

  1. Create a temporary self-signed cert so nginx can start
  2. Bring up the stack
  3. Obtain a real Let's Encrypt cert via the ACME webroot challenge
  4. Reload nginx with the real cert
  5. Print next steps

3. Complete Forgejo setup

Visit https://your-domain/ and complete the web installer. Create your admin account. Then set DISABLE_REGISTRATION=true in .env and run:

docker compose up -d forgejo

4. Configure geo-blocking

Edit config/geo_rules.yml — the watcher will detect the change within seconds and hot-reload nginx automatically. No restart needed.


Geo-Blocking Configuration

config/geo_rules.yml is the single source of truth. Example:

repos:

  - path: /alice/secret-project
    rules:
      # Block California and Texas with HTTP 451
      - locales: ["US-CA", "US-TX"]
        status: 451
        body: "This repository is unavailable in your jurisdiction."

      # Block all of Germany and France with HTTP 403
      - locales: ["DE", "FR"]
        status: 403
        body_file: secret-project-de-fr.html  # HTML file in config/geoblock_pages/

  - path: /alice/another-repo
    rules:
      - locales: ["CN", "RU"]
        status: 403
        body: "Access denied."

Locale format

Format Example Matches
Country (ISO 3166-1 α-2) "US" All IPs in the United States
Country + State (ISO 3166-2) "US-CA" IPs in California

State-level rules take precedence over country-level rules for the same repo.

Common US state codes: US-AL US-AK US-AZ US-AR US-CA US-CO US-CT US-DE US-FL US-GA US-HI US-ID US-IL US-IN US-IA US-KS US-KY US-LA US-ME US-MD US-MA US-MI US-MN US-MS US-MO US-MT US-NE US-NV US-NH US-NJ US-NM US-NY US-NC US-ND US-OH US-OK US-OR US-PA US-RI US-SC US-SD US-TN US-TX US-UT US-VT US-VA US-WA US-WV US-WI US-WY

For other countries, find subdivision codes at: https://www.iso.org/obp/ui/#search (search for the country, then see "Subdivision")

HTTP status codes

Code Meaning When to use
403 Forbidden General access restriction where you can disclose the repository exists
404 Not Found General access restriction where you can't
451 Unavailable For Legal Reasons Legal / jurisdictional block (RFC 7725) where you can explain why

Hot reload

The watcher polls every 60 seconds and also reacts to inotify events immediately. After saving config/geo_rules.yml, nginx will reload within seconds. No traffic is dropped — nginx does a graceful configuration reload (SIGHUP).


GeoIP Database Updates

The geoipupdate container fetches a fresh GeoLite2-City database every 72 hours (MaxMind publishes updates twice a week). The database is stored in the geoip_db Docker volume and mounted read-only into nginx.

nginx reads the database file at request time (not cached in memory), so a fresh database takes effect for the next request after the file is replaced — no nginx reload required.


Certificate Renewal

The certbot container runs certbot renew every 12 hours. When a certificate is renewed, run:

docker compose exec nginx nginx -s reload

Or add this as a cron job on the host:

0 */12 * * * docker compose -f /path/to/docker-compose.yml exec nginx nginx -s reload

Publishing an F-Droid Repository (optional)

If projects hosted here ship Android apps, the fdroid service turns their release .apk assets into an F-Droid repository served at https://<domain>/fdroid/repo. Users add it once in the F-Droid client and get updates from then on — on Android 12+, silently in the background for apps F-Droid installed itself.

The service polls Forgejo over the internal compose network, so it needs no credentials, no deploy key, and no changes to anyone's CI. Publishing is a pull, not a push.

Setup

1. Create the index signing key (once, and back it up — see the warning below). Needs keytool, which a JRE provides — no JDK required on the host, the JDK lives inside the service image:

sudo apt install --no-install-recommends default-jre-headless
./bootstrap_fdroid_key.sh

It generates the keystore password itself and writes it into .env — there is nothing to fill in beforehand. (Leave FDROID_KEYSTOREPASS/FDROID_KEYPASS blank; they end up holding the same value, because keytool's PKCS12 keystores cannot have a key password distinct from the store password.)

Do this before starting the service: docker compose bind-mounts config/fdroid-keystore.jks, and Docker silently creates a directory at that path if the file does not exist yet.

2. Configure the sources:

cp config/fdroid.yml.example config/fdroid.yml
$EDITOR config/fdroid.yml        # repo_url, repo_name, and one entry per source repo

3. Start it, and rebuild nginx so it serves /fdroid/:

docker compose up -d --build fdroid nginx
docker compose logs -f fdroid

The nginx rebuild is required rather than a reload: conf.d is baked into the image (the template is rendered at container start), so a new location block only exists after a rebuild.

What a source repository has to provide

  • A public Forgejo repo with releases carrying .apk assets.
  • One <applicationId>.yml fdroidserver metadata file per app, in the directory named by metadata_path. These are read at the release tag, so listings can never describe a different version than the APKs do.
  • AllowedAPKSigningKeys set in each of those files, pinning the certificate the APKs are signed with. This is load-bearing — see below.

Prereleases are skipped, so -rc tags never reach devices.

Why the signing-key pin matters

This service republishes release assets automatically, and F-Droid installs are in-place upgrades on real devices. AllowedAPKSigningKeys is what stops an APK signed with a different key from being served as an upgrade.

It needs checking, because a rejection is not an error. Given a mismatch, fdroid update warns, drops the APK, signs an index without it, and exits 0:

WARNING: Removing repo/example_1.0.0.apk
INFO: Creating signed index with this key (SHA256): …
INFO: Finished

A signed index, a success exit, and one app silently missing. sync.py therefore verifies every downloaded APK actually appears in index-v2.json and refuses to publish otherwise, leaving the previously published repository live.

Operational notes

  • The signing key is permanent. Its fingerprint is part of the repository URL every device is configured with. config/fdroid-keystore.jks is the one thing in this stack that cannot be regenerated — back it up off the machine.

  • Disk grows with releases. Every version stays installable (archive_older: 0), so the volume grows by the size of each release's APKs. To cap it, set archive_older in sync.py's generated config, or prune work/repo/ by hand.

  • Publishing is atomic. Each run builds a repo-<tag> snapshot (hardlinks, so it is instant and free) and moves a symlink. Rollback is re-pointing /srv/fdroid/repo at an older snapshot; the last three are kept.

  • Force a republish with docker compose restart fdroid after deleting state.json:

    docker compose exec fdroid rm /srv/fdroid/work/state.json
    docker compose restart fdroid
    
  • The repository is not geo-blocked by default, which is deliberate — see the note in config/geo_rules.yml.example before trying to add a rule for it.

  • Testing changes to sync.pyfdroid/test_sync.py exercises the whole flow (including the wrong-key refusal) against a real instance, in a temp tree that touches neither the volume nor the live repository:

    sudo apt install --no-install-recommends fdroidserver default-jdk-headless
    FORGEJO_URL=https://git.example.com TEST_REPO=alice/my-app \
        python3 fdroid/test_sync.py
    

    It downloads the real release assets, so expect it to pull tens of MB. The source repo must already carry its metadata at its latest release tag.


Operations

View logs

docker compose logs -f nginx          # access + error logs
docker compose logs -f geoblock_watcher
docker compose logs -f forgejo
docker compose logs -f geoipupdate

Test geo-blocking (from a blocked region)

Use a proxy or VPN to simulate a request from a blocked locale, or test directly with curl overriding your IP (only works if you control nginx):

# Verify nginx config is valid after a rules change
docker compose exec nginx nginx -t

# Force a manual nginx reload
docker compose exec nginx nginx -s reload

Verify the GeoIP database is loaded

docker compose exec nginx nginx -T | grep geoip2

Check which database version is in use

docker compose exec geoipupdate cat /usr/share/GeoIP/GeoLite2-City_*/COPYRIGHT_AND_LICENSE

Security Notes

  • SSH is disabled in Forgejo; all Git operations use HTTPS to simplify geofencing.
  • Registration is disabled by default after initial setup — only the admin can create accounts.
  • nginx does not forward X-Forwarded-For from downstream; it sets it from $remote_addr (the actual connected IP). This is intentional — we explicitly trust the direct connection IP.
  • The docker.sock mount on geoblock_watcher is the minimum necessary to send SIGHUP to the nginx container. If this is a concern, you can replace it with a small privileged sidecar that only accepts a reload signal.

Troubleshooting

Symptom Check
nginx won't start docker compose logs nginx — likely a config syntax error
GeoIP variables always empty Is the geoip_db volume populated? Check docker compose logs geoipupdate
Rules not applied Check docker compose logs geoblock_watcher — look for YAML parse errors
Certificate errors Ensure port 80 is open and DNS resolves before running bootstrap_certs.sh
502 Bad Gateway Forgejo not healthy yet — check docker compose logs forgejo

Generative AI disclosure

I used Claude both as a chatbot and as a coding agent to write and debug this configuration and reviewed manually prior to publishing. Where relevant, I included prompts in commit descriptions.

Contributions written in whole or in part utilizing generative AI are welcome; however, they will be reviewed as if you wrote them yourself.