Doc · 01 · Infrastructure

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 compose command).
  • ~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.
NOTE

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 (apksigner for signing the F-Droid index), postgresql-client-16 (backup feature), and the trivy CLI (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

  1. Clone & configure

    Generate a strong SECRET_KEY and pick a starter admin password. Everything else has defaults you can tune later.

    bash setup
    git 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 set INITIAL_ADMIN_PASSWORD to something only you know — it's used once on first boot and ignored thereafter.

  2. Boot

    Pulls the published images, brings up postgres, redis, backend, worker and frontend.

    bash up
    docker compose up -d
  3. Open the UI

    Frontend is on port 8080 by default. Log in with INITIAL_ADMIN_EMAIL + INITIAL_ADMIN_PASSWORD.

    • http://localhost:8080 — admin & client UI
    • http://localhost:8000/api/docs — Swagger (when ENVIRONMENT=development)
    • http://localhost:8080/fdroid/repo — F-Droid repo path
  4. 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.

  5. First reindex

    Go to /admin/repo and 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
development or production. production disables Swagger and hardens cookie flags.
LOG_LEVEL
DEBUG · INFO · WARNING. INFO is 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:4954 when using the bundled trivy compose 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 /tmp tmpfs 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.

.env local fs
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.

.env s3-compatible
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
NOTE

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.

WARN

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

.envlocal
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.

.envoidc
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.

NOTE

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.

TIP

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_required token 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:

  1. Set the env vars

    In your .env:

    .envclamav
    CLAMAV_HOST=clamav            # the compose service name
    CLAMAV_PORT=3310
    CLAMAV_MAX_STREAM_MB=100     # matches clamd's StreamMaxLength
  2. Start the stack with the profile

    The profile flag adds the clamav container to up / down calls.

    bashup
    docker compose --profile clamav up -d
  3. Turn it on in admin

    Open /admin/scanning (renamed from /admin/scans in 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.

NOTE

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.

  1. Set the env var

    In your .env:

    .envtrivy
    TRIVY_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.

  2. Start the stack with the profile

    The profile flag adds the trivy container to up / down calls.

    bashup
    docker compose --profile trivy up -d
  3. Turn it on in admin

    Open /admin/scanning → toggle CVE / SBOM scanning. The admin chip mirrors the env knob — when TRIVY_SERVER_URL is 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].

PRIVACY

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.enc archive 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_tmp volume 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:

.envbackup
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.

NOTE

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 the has_access_token boolean is ever returned by the API.
  • Server-wide (env): a fallback for the operator, used when a source has no per-source PAT.
.envforge fallback tokens
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

Caddyfiletls + reverse_proxy
apks.example.com {
  encode zstd gzip
  reverse_proxy localhost:8080
}

nginx (host-level, in front of the compose stack)

nginx.conftls front
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;
  }
}
HSTS

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_only with no-new-privileges and 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 frontend nginx layer. Inspect frontend/nginx.conf.template if you need to add a CDN domain to img-src.
  • Backups: the built-in /admin/backup flow (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 the postgres and storage volumes 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.

bashupgrade
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.

bashenable the reference proxy
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 opaque credential_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).

SAFETY

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.