Install & Infrastructure.
Everything required to bring a fdroid-store instance up: prerequisites, environment variables, storage, OIDC, ClamAV, and reverse-proxy recipes. If you only want to publish APKs from CI once the host is up, jump to integrations.
01Prerequisites
fdroid-store ships as a docker-compose stack. You need:
- Docker Engine 24+ and Compose v2 (
docker composecommand). - ~2 GB RAM for the baseline (postgres + redis + backend + worker + nginx). Add ~1 GB if you enable ClamAV.
- Disk: enough for your APKs × retention × (public + private) indexes. Plan generously.
- A public hostname pointing at the host (recommended for the F-Droid client to fetch over HTTPS).
- Outbound HTTPS if you want forge auto-ingest (GitHub / GitLab / Gitea) or OIDC.
Three published images are pulled automatically by the stock docker-compose.yml:
ghcr.io/dim145/fdroid-store-backend— pure-Python API (FastAPI + uvicorn)ghcr.io/dim145/fdroid-store-worker— extends the backend image with the JDK (apksignerfor signing the F-Droid index),postgresql-client-16(backup feature), and thetrivyCLI (CVE scan, client mode)ghcr.io/dim145/fdroid-store-frontend— Next.js SPA + nginx
The worker pins its base layer to the backend image by content digest in CI, so the two never drift. Set them to build: stanzas if you fork.
02First boot
-
Clone & configure
Generate a strong
SECRET_KEYand pick a starter admin password. Everything else has defaults you can tune later.bash setupgit clone https://github.com/Dim145/fdroid-store cd fdroid-store cp .env.example .env python3 -c 'import secrets; print(secrets.token_urlsafe(64))'
Paste the printed value as
SECRET_KEY=…. Then setINITIAL_ADMIN_PASSWORDto something only you know — it's used once on first boot and ignored thereafter. -
Boot
Pulls the published images, brings up
postgres,redis,backend,workerandfrontend.bash updocker compose up -d
-
Open the UI
Frontend is on port
8080by default. Log in withINITIAL_ADMIN_EMAIL+INITIAL_ADMIN_PASSWORD.http://localhost:8080— admin & client UIhttp://localhost:8000/api/docs— Swagger (whenENVIRONMENT=development)http://localhost:8080/fdroid/repo— F-Droid repo path
-
Run the setup wizard
Visit
/admin/setup. Choose Generate to mint a fresh signing keystore, or Import if you already have a.p12/.jks. The keystore lives in a Docker volume from this point on. -
First reindex
Go to
/admin/repoand trigger a reindex. After ~30s the F-Droid path serves a valid (empty) index. You can now upload APKs.
03Environment reference
All knobs live in .env. The full annotated file is at .env.example; the table below covers the ones most people touch.
Identity & URLs
- SECRET_KEY
- Random ≥ 64 chars. Signs JWTs, derives the Fernet key for PATs, signs session cookies. Rotating invalidates everything. required
- PUBLIC_REPO_URL
- The URL the F-Droid Android client will hit. Must be reachable from end-user devices. e.g.
https://apks.example.com/fdroid/repo. required - PUBLIC_APP_URL
- Frontend public URL — used in OIDC redirect URIs and audit-log links.
- PUBLIC_API_URL
- Backend public URL — what the browser SPA calls. Defaults to
PUBLIC_APP_URL. - ENVIRONMENT
developmentorproduction.productiondisables Swagger and hardens cookie flags.- LOG_LEVEL
DEBUG·INFO·WARNING.INFOis the default and what we recommend.
Database & queue
- POSTGRES_USER
- Postgres role. default fdroid
- POSTGRES_PASSWORD
- Postgres password. Change it. required
- POSTGRES_DB
- Database name. default fdroid
- DATABASE_URL
- Must use the async driver:
postgresql+asyncpg://…. - REDIS_URL
- e.g.
redis://redis:6379/0.
Initial admin
- INITIAL_ADMIN_EMAIL
- Email seeded on first boot — also the username for login. required
- INITIAL_ADMIN_PASSWORD
- Hashed with argon2 on first boot, then the env var is ignored. Rotate via the UI. required
- ACCESS_TOKEN_EXPIRE_MINUTES
- Short-lived bearer token TTL. default 60
- REFRESH_TOKEN_EXPIRE_DAYS
- Refresh-token TTL. Rotated on each use. default 30
F-Droid repo
- REPO_NAME
- Shown in the F-Droid client header. Free text.
- REPO_DESCRIPTION
- Subtitle in the F-Droid client.
- KEYSTORE_PATH
- Path inside the container — keep the default unless you mount your own. Auto-created on first wizard run.
- KEYSTORE_PASSWORD · KEY_PASSWORD
- Both must be set before the wizard runs. required
- KEY_ALIAS · KEY_DNAME
- Used when the wizard generates a fresh key.
CORS
- CORS_ORIGINS
- Comma-separated list of origins allowed to call the API. e.g.
https://apks.example.com. The browser SPA on the same host doesn't need to be listed.
Scanners (opt-in)
- CLAMAV_HOST · CLAMAV_PORT
- Host + port of the optional ClamAV daemon. Empty
CLAMAV_HOST= malware-scan feature disabled. default empty - CLAMAV_MAX_STREAM_MB
- Mirrors clamd's
StreamMaxLength. default 100 - TRIVY_SERVER_URL
- URL of the optional Trivy server for per-APK SBOM extraction + CVE lookup. The worker talks to it in CLIENT mode — the DB stays on the server. Empty = CVE/SBOM feature disabled (worker short-circuits to
SKIPPED). e.g.http://trivy:4954when using the bundledtrivycompose profile. default empty
Backup & restore
- BACKUP_TMP_DIR
- Working directory the encrypted-backup worker uses to stage the in-progress tarball + extraction tree. Bind a persistent volume (the stock compose does — see
backup_tmp); the default/tmptmpfs is 512 MB and too small for real-sized repos. default /data/backup-tmp in compose
04Storage backends
One env var picks where APKs and indexes live. The active backend is held by all the backend + worker containers.
Local filesystem
Cheap, fast, and the default. APKs land under /data/storage inside the container, which the stock compose mounts as a named volume. nginx serves public APKs directly and private APKs via X-Accel-Redirect.
STORAGE_BACKEND=local LOCAL_STORAGE_PATH=/data/storage
S3 (or any S3-compatible)
Works with AWS S3, MinIO, Wasabi, Backblaze B2, Cloudflare R2, etc. Set S3_ENDPOINT_URL for anything that's not AWS.
STORAGE_BACKEND=s3 S3_ENDPOINT_URL=https://s3.eu-west-3.amazonaws.com # or your MinIO S3_REGION=eu-west-3 S3_BUCKET=fdroid-store S3_ACCESS_KEY=AKIA... S3_SECRET_KEY=... S3_USE_PATH_STYLE=true # MinIO/B2 wants true; AWS wants false S3_PUBLIC_BASE_URL= # kept for backward compat — no longer used for downloads
Downloads always stream through the backend regardless of S3_PUBLIC_BASE_URL. The historical "302 to a public S3 URL" shortcut bypassed audit, private-app gating and rate limits, and broke against S3 backends that refuse anonymous reads (Garage, private MinIO). The setting is kept in the schema only so existing .env files don't fail validation.
Switching backends mid-life is not a hot-swap. Copy the bucket contents before flipping the env var, and trigger a full reindex from /admin/repo afterwards.
05Authentication & OIDC
Two modes can run together: local password (argon2) and OIDC (Authlib). Each user account binds to one or both.
Local only
AUTH_METHODS=local ALLOW_SIGNUP=true # or false if you want invite-only
OIDC (Keycloak, Authentik, Google, …)
Add oidc to AUTH_METHODS and provide the issuer + client credentials. The OIDC Sign in button appears on the login page.
AUTH_METHODS=local,oidc OIDC_ISSUER=https://auth.example.com/realms/myrealm OIDC_CLIENT_ID=fdroid-store OIDC_CLIENT_SECRET=... OIDC_SCOPES=openid profile email # optional: auto-promote on group membership OIDC_ADMIN_CLAIM=groups=fdroid-admins # default true — reject callbacks whose ``email_verified`` claim is # False/missing (account-takeover defence). Set false ONLY for IdPs # that don't emit the claim (Defguard, some Keycloak realms). OIDC_REQUIRE_EMAIL_VERIFIED=true
The redirect URI to register with your IdP is $PUBLIC_API_URL/api/v1/auth/oidc/callback.
When OIDC_REQUIRE_EMAIL_VERIFIED=false the backend logs a WARNING on every startup naming the env var and the attack vector it disables, so the disabled state stays visible in your logs.
TOTP (second factor)
TOTP enrolment is per-user — no env var required. Each user goes to /account → Two-factor, scans the QR with any RFC 6238 app (Aegis, Bitwarden, Google Authenticator, 1Password…) and confirms with a 6-digit code. Subsequent logins prompt for the code after the password.
Admins can require TOTP by disabling password-only sessions in /admin/access once the team has all enrolled.
WebAuthn / passkeys
Each user can enrol one or more FIDO2 authenticators from /account/security → Passkeys — platform authenticators (Touch ID, Windows Hello, Android biometrics) or roaming keys (YubiKey, Solo, …). Once enrolled, the passkey replaces the password on subsequent logins (or acts as a second factor on top of the password if you prefer that).
Admins can force passkey enrolment per role from /admin/access:
- Require passkey for admins — every admin must enrol at least one passkey to log in. Accounts caught between the toggle flip and their first enrolment receive a short-lived
enrollment_requiredtoken at the post-password step and are routed through a forced enrolment screen. - Require passkey for uploaders — same policy for the uploader role.
No new env var. The relying-party ID is derived from PUBLIC_APP_URL; passkeys are bound to that origin and won't follow you if you rename your instance.
06ClamAV — opt-in malware scan
fdroid-store ships a ClamAV-ready compose profile. Without it, the clamav service stays down and uploads aren't scanned. Enable it in two steps:
-
Set the env vars
In your
.env:.envclamavCLAMAV_HOST=clamav # the compose service name CLAMAV_PORT=3310 CLAMAV_MAX_STREAM_MB=100 # matches clamd's StreamMaxLength
-
Start the stack with the profile
The profile flag adds the
clamavcontainer toup/downcalls.bashupdocker compose --profile clamav up -d
-
Turn it on in admin
Open
/admin/scanning(renamed from/admin/scansin v1.2 — that's where every malware/CVE/RB toggle now lives) and flip Scan on upload. The freshclam container takes ~5 min on first boot to download its signature database — until it's ready, the page shows a red ClamAV unreachable chip.
If CLAMAV_HOST is set but the container isn't running, uploads with scan-on-upload enabled return 503 scanner unavailable. Either start the profile or clear the env var.
07Trivy CVE/SBOM — opt-in vulnerability scan
fdroid-store can extract a CycloneDX SBOM and run a CVE lookup for every published APK using Aqua Trivy. The bundled compose stack ships a trivy server profile so you don't need to pull the binary or the vulnerability database onto your worker.
How it works: the worker has the trivy CLI baked in and talks to the trivy server in CLIENT mode. File parsing happens on the worker, DB lookups go to the server. That means the ~200 MB CVE DB lives on the server only — refreshed daily, persisted across restarts via the trivy_cache volume — and multiple workers share it.
-
Set the env var
In your
.env:.envtrivyTRIVY_SERVER_URL=http://trivy:4954 # the compose service name + internal port
If you point at an external Trivy server, that URL replaces
http://trivy:4954. The worker reaches it over the compose network; never expose Trivy publicly — it has no auth. -
Start the stack with the profile
The profile flag adds the
trivycontainer toup/downcalls.bashupdocker compose --profile trivy up -d
-
Turn it on in admin
Open
/admin/scanning→ toggle CVE / SBOM scanning. The admin chip mirrors the env knob — whenTRIVY_SERVER_URLis unset, the toggle is locked off and the page surfaces the env hint instead. Every newly attached APK is queued for a scan; manual re-scans are available from each APK's row in/my-apps/[id].
SBOM + CVE results are visible to the app's owner, its collaborators and admins only — they never appear on the public catalogue. The exact CVE list can be sensitive (it gives an attacker a roadmap), so we keep it gated.
08Backup & restore
Encrypted full-repo archives are produced asynchronously by the worker, then streamed back through the API. The workflow lives under /admin/backup:
- Pick the components you want (DB / keystore / assets / APKs — any non-empty subset).
- Provide a passphrase: the worker derives the encryption key from it and the resulting
.tar.encarchive can't be opened without it. The passphrase is never persisted — losing it loses the backup. - Watch the job progress; once it lands in the history list, download the archive. The compose stack keeps every produced archive on the
backup_tmpvolume so a re-download doesn't re-run the job. - Restore is selective too — drop the encrypted archive on the same page, type the passphrase, tick the components you want to apply.
Operator-relevant env + volume:
BACKUP_TMP_DIR=/data/backup-tmp # default in docker-compose; persistent volume
The stock compose mounts a named backup_tmp volume there so the working set survives a worker restart. If you change the path, make sure it's a real volume (not the 512 MB /tmp tmpfs) or a multi-GB backup will run out of room.
The keystore component is the most operationally critical: lose it and every F-Droid client that already added your repo refuses to update past that point (signing key mismatch). Take a backup right after the setup wizard mints the keystore, and keep a copy off-host.
09Forge tokens (optional)
fdroid-store can auto-fetch new APK releases from GitHub, GitLab, and Gitea / Forgejo (including self-hosted instances). Tokens unlock private repos and raise the API rate budget.
Two ways to provide a token — they coexist; per-source wins:
- Per-source (UI): users paste a PAT in the app's source panel. It's encrypted at rest with Fernet (key derived from
SECRET_KEY) and only thehas_access_tokenboolean is ever returned by the API. - Server-wide (env): a fallback for the operator, used when a source has no per-source PAT.
GITHUB_TOKEN=ghp_... GITLAB_TOKEN=glpat-... GITEA_TOKEN=...
For self-hosted GitLab / Gitea, the user provides the base_url on the source itself (e.g. https://forge.internal). It goes through the SSRF guard before any request is made.
10Reverse proxy & TLS
The bundled frontend container is itself nginx. For production you'll want a second layer that terminates TLS and forwards to it. Two recipes:
Caddy
apks.example.com {
encode zstd gzip
reverse_proxy localhost:8080
}
nginx (host-level, in front of the compose stack)
server {
listen 443 ssl http2;
server_name apks.example.com;
ssl_certificate /etc/letsencrypt/live/apks.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/apks.example.com/privkey.pem;
client_max_body_size 200m; # raise above your max APK size
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Set ENABLE_HSTS=1 on the frontend container env to make the bundled nginx emit a Strict-Transport-Security header. Only do this once TLS is confirmed working — HSTS is sticky in browsers.
11Hardening notes
- SECRET_KEY rotation: invalidates all JWTs, refresh tokens, OIDC state cookies, and decrypts any stored Fernet PATs. Plan for users to re-login and re-enter forge PATs.
- Containers run
read_onlywithno-new-privilegesand all caps dropped. Don't relax this unless you have a specific reason. - Rate limits (slowapi): login/signup/MFA 5/min, refresh 10/min, OIDC 20/min, app list 10/min, forge upsert 10/min, APK inspect 10/min. Tune by editing the
@limiter.limit()decorators if needed. - SSRF: forge fetches resolve DNS, reject metadata IP ranges (link-local, AWS/GCP/Azure metadata, RFC1918 if you don't host your forge there), walk redirects manually with the same checks, strip userinfo from URLs.
- CSP / X-Frame-Options / X-Content-Type-Options are emitted by the
frontendnginx layer. Inspectfrontend/nginx.conf.templateif you need to add a CDN domain toimg-src. - Backups: the built-in
/admin/backupflow (see section 08) produces encrypted, passphrase-protected archives that cover the DB, storage, keystore and assets in one shot. For a host-level snapshot fallback, copy thepostgresandstoragevolumes together. - Encryption at rest: per-source forge PATs are stored Fernet-encrypted with a key derived from
SECRET_KEY. The admin Backup feature reuses the same recipe (passphrase-derived key, AES + HMAC) for the archive — the passphrase itself is never persisted.
12Upgrading
Migrations run automatically on backend boot — there's nothing to invoke by hand.
docker compose pull docker compose up -d # or add --profile clamav docker compose logs -f backend # watch the Alembic output
Rolling back a release means rolling back the image tag and the database. Take a pg_dump before any upgrade you're not sure about.
13Source proxies (opt-in)
For APK sources that don't fit the forge model (F-Droid mirrors, Patreon, private artefact registries…), fdroid-store delegates to external source proxy services. The proxy speaks a small v1 HTTP protocol over Bearer auth; the proxy author owns the scraping / ToS / legal burden, and you only run the proxies you actually want.
Reference F-Droid proxy
A reference implementation that fetches APKs from any F-Droid-compatible repo (f-droid.org, IzzyOnDroid, Guardian Project, another fdroid-store…) ships in the compose stack behind the proxy-fdroid profile. Off by default.
docker compose --profile proxy-fdroid up -d
By default the proxy listens on proxy-fdroid:8000 inside the compose network and runs in open mode (no shared secret) — fine for a single-host deployment where only the backend reaches it. To require a Bearer secret, set PROXY_SHARED_SECRET in the proxy's environment, then paste the same value into /admin/proxies when registering it.
Register the running proxy from /admin/proxies with http://proxy-fdroid:8000 as the base URL — the admin UI immediately probes /healthz + caches the catalogue.
Writing your own proxy
The proxy protocol is six endpoints:
GET /healthz— liveness, no auth.GET /sources— catalogue of providers + their auth shape.POST /resolve— fetch the latest release for a URL + auth blob, return APK URL + metadata.GET /auth/<provider>/begin+GET /auth/<provider>/callback— OAuth dance (proxy stores tokens, hands fdroid-store an opaquecredential_id).GET <apk_url>— the actual download, ideally a redirect to upstream or a short-lived signed URL.
Full spec + payload shapes: docs/proxy-protocol.md. Reference impl: proxy/fdroid/main.py (~350 lines of FastAPI).
fdroid-store enforces several invariants the proxy can't override: SSRF guard on apk_url (RFC 1918 / loopback / metadata IPs refused), admin-configured size cap during the streamed download, optional SHA-256 verification when the proxy provides apk_sha256_hint, and a manifest-vs-advertised version_code / package_name cross-check. A lying proxy is rejected before its bytes hit the catalogue.