Skip to content

Scaling & Quotas

FreeSDN ships one codebase and one database schema. You pick a deployment tier by pointing docker compose at the right env file, and you optionally turn on org quotas if you need SaaS-style resource ceilings. On a self-hosted install, quotas are off by default and every org is unlimited.


Quota enforcement is controlled by a single environment variable:

ENFORCE_ORG_QUOTAS=false # default - unlimited self-hosted

When ENFORCE_ORG_QUOTAS is false (the default), every quota check returns immediately without counting anything. No org can exceed a limit because no limit is applied. Flip it to true only if you are running a multi-tenant SaaS deployment and need per-org ceilings.

When enforcement is on, each org’s tier is read from organization.settings["tier"] (a field in the org’s JSONB settings column, not a dedicated database column). If the field is absent or invalid the org falls back to FREE.

You set or change a tier by patching the org’s settings object. Tier lives inside organization.settings["tier"] (a JSONB key), not as a top-level field on the org:

PATCH /api/v1/organizations/{org_id}
Content-Type: application/json
{ "settings": { "tier": "professional" } }

The following table shows the hard limits per tier. All counts are per-org.

ResourceFREESTARTERPROFESSIONALENTERPRISEUNLIMITED
Max users31050500999,999
Max admins121050999,999
Max sites1520100999,999
Max devices (org-wide)101005005,000999,999
Max devices per site1050100500999,999
Max controllers11050200999,999
API rate limit (req/min)1005002,00010,000999,999
Max API keys ¹15205050
Audit retention (days)730903659,999
Metric retention (days)1730909,999

¹ Max API keys - partially enforced. The per-tier values are stored in the quota struct (TIER_QUOTAS in app/services/organization.py) but _check_quota() does not currently handle the api_keys resource string - it silently returns without checking. The actual enforcement today is a flat ceiling of 50 active keys per user (all tiers, all orgs), applied in app/api/v1/endpoints/api_keys.py. The per-tier differentiation (FREE = 1, STARTER = 5, …) is not yet active.

Feature flags per tier:

Feature flagFREESTARTERPROFESSIONALENTERPRISE / UNLIMITED
basic_monitoringyesyesyesyes
webhooks-yesyesyes
automation-yesyesyes
backup--yesyes
api_access--yesyes
All features---yes

The quota check function (_check_quota) does an atomic SELECT … FOR UPDATE on the org row before any insert, closing the count-then-insert race window. The following operations are gated:

ActionQuota resource checked
Adopt a single devicedevices
Batch-adopt devicesdevices (whole-batch atomic check: increment = batch size)
Create a controllercontrollers
Add an org memberusers
Promote a member to adminadmins
Create a sitesites

When a limit is reached the API returns 403 with a QuotaExceededError body naming the resource. If ENFORCE_ORG_QUOTAS is false, the check returns immediately and the 403 path is never reached.

Auth requirements differ per endpoint:

MethodPathWho can call it
GET/api/v1/organizations/Any authenticated user - non-super-admins are silently filtered to their own org
POST/api/v1/organizations/SUPER_ADMIN only
GET/api/v1/organizations/{org_id}SUPER_ADMIN, or any member whose account belongs to that org
PATCH/api/v1/organizations/{org_id}SUPER_ADMIN, or the org’s own ORG_ADMIN
DELETE/api/v1/organizations/{org_id}SUPER_ADMIN only
GET/api/v1/organizations/{org_id}/dashboardSUPER_ADMIN, or any member whose account belongs to that org

Tier update is a PATCH with settings.tier in the request body (accepted values: free, starter, professional, enterprise, unlimited). There is no top-level tier field; the tier is nested inside the settings JSONB object as shown in the example above. Only a SUPER_ADMIN may change the tier - for any other caller the value is silently dropped and the existing tier is preserved.


FreeSDN ships one docker-compose.yml base stack. You select a scale tier by pointing --env-file at the matching file. Profiles add optional workloads on top.

TierTargetKey additions
LiteHomelab / single node1 API worker, no extras
ProSMBio-worker profile + monitoring (Flower)
MaxEnterprisepooling (PgBouncer) + dr (off-site backup) + HA overlay
DevDevelopmentHot-reload, debug middleware

Start commands:

Terminal window
# Lite
docker compose --env-file .env.lite up -d
# Pro
docker compose --env-file .env.pro up -d
# Max
docker compose --env-file .env.max up -d
# Dev
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.dev up -d

Or use the one-command installer, which picks the right env file automatically:

Terminal window
./install.sh --tier pro --domain sdn.example.com

These run in every tier:

  • postgres - PostgreSQL 18.4 primary
  • logdb - TimescaleDB (metrics / events / heartbeats / time-series)
  • redis - Valkey 8.1 (cache / broker / results; redis:// URLs retained for compatibility)
  • api - FastAPI/gunicorn API workers
  • worker - Celery worker
  • scheduler - Celery beat (periodic tasks)
  • pg-backup - GPG-encrypted DB backup with 7-day prune
  • caddy - Edge proxy with automatic HTTPS

The data tier is internal-only. Only the Caddy edge publishes host ports.

Enable with COMPOSE_PROFILES=<name> in your env file or by passing --profile to docker compose:

ProfileWhat it addsRecommended for
io-workerDedicated I/O-bound Celery workerPro / Max
monitoringFlower dashboardPro / Max
camerasgo2rtc restreamDeployments using cameras
poolingPgBouncer connection poolerMax / high concurrency
drrclone off-site backupMax / disaster-recovery
metricsPrometheus + exportersMax / observability
nginxnginx edge escape-hatchWhen Caddy is not suitable

Each API worker process opens its own async connection pool (SQLAlchemy 2.0 + asyncpg):

  • Pool size: 20 connections
  • Overflow: 30 connections
  • Total per-worker maximum: 50 connections

With multiple gunicorn workers the total open connections = workers × 50. Multiply by your replica count if you run the HA overlay.

If your PostgreSQL server cannot handle that load, enable PgBouncer (the pooling profile). PgBouncer sits between the API workers and Postgres, multiplexing connections in transaction mode. This is the recommended approach for the Max tier or any deployment with more than two API worker replicas.

The default stack runs a single Valkey master. The Max tier adds:

  • 1 Valkey replica
  • 3 Valkey Sentinels (authentication + resolve-hostnames enabled)

Sentinel provides automatic failover (master promotion takes roughly 9 seconds; the API follows within about 5 seconds via the master_for re-resolve in app/core/redis_client.py).

The HA overlay is in docker-compose.ha.yml and is applied alongside .env.max:

Terminal window
docker compose -f docker-compose.yml -f docker-compose.ha.yml --env-file .env.max up -d

The API enforces rate limits at the edge, applied per-principal:

CategoryLimit (default)
General requests100 / minute (RATE_LIMIT_DEFAULT)
Authentication endpoints5 / minute (RATE_LIMIT_AUTH)
Global per-principal ceiling600 req / min (RATE_LIMIT_RPM), burst 120 (RATE_LIMIT_BURST)

These limits are applied by RateLimitMiddleware and are configured entirely via environment variables - they are not tied to org tier or ENFORCE_ORG_QUOTAS. The api_rate_limit field in the tier quota table above is stored on the quota record but is not yet read by any rate-limiting middleware. Turning on ENFORCE_ORG_QUOTAS does not activate per-org rate-limit enforcement; it only gates resource-count operations (devices, sites, controllers, users, admins). The effective rate limits are always the global values shown above, regardless of org tier.


Retention limits in the quota table (audit days, metric days) are stored on the org record and are read by the platform’s data-prune jobs. When ENFORCE_ORG_QUOTAS is false, no prune ceiling is enforced by the quota system - your actual retention is bounded only by disk space and the TimescaleDB chunk policies you configure.

The audit log export endpoint hard-caps at 10,000 rows per request regardless of quota tier. If your query matches more than 10,000 rows the response includes X-Result-Truncated, X-Result-Total, and X-Result-Limit headers and the JSON body contains {truncated: true, total, limit, returned, items}.


The following guidance is based on testing against the live codebase. It is not a vendor benchmark.

Devices managedSuggested tierWorkersNotes
< 50Lite1Single-box homelab
50-500Pro2-4io-worker + monitoring recommended
500-5,000Max4-8PgBouncer + HA overlay + io-worker
> 5,000Max + customscale horizontallyContact for guidance

Memory baseline per API worker: the API_MEM_LIMIT and WEB_CONCURRENCY env vars must be tuned together. An undersized API_MEM_LIMIT against a high WEB_CONCURRENCY count causes OOM kills. A safe starting ratio is ~512 MiB per worker; raise API_MEM_LIMIT before raising WEB_CONCURRENCY.


All variables below use bare names - there is no FREESDN_ prefix. Set them directly in your env file or as environment variables (e.g. ENFORCE_ORG_QUOTAS=true). See backend/app/core/config.py for the full list.

VariableDefaultPurpose
ENFORCE_ORG_QUOTASfalseEnable per-org tier quota enforcement
PUBLIC_BASE_URLhttp://localhost:8000External base URL for notification action links and agent WebSocket
WEB_CONCURRENCYtier-dependentNumber of gunicorn workers
API_MEM_LIMITtier-dependentMemory limit per API container - must be sized with WEB_CONCURRENCY
METRICS_AUTH_TOKEN(required in prod)Bearer token for /metrics; endpoint not served without it
CADDY_SITE_ADDRESS:80:80 = HTTP/behind-LB, localhost = local TLS, domain = Let’s Encrypt

To see an org’s current stats and tier, call:

GET /api/v1/organizations/{org_id}

The response includes top-level fields site_count, user_count, and device_count, alongside the org’s settings.tier value. There is no stats sub-object - these counts are at the root of the JSON response. Controller counts are not part of this response; they are available via GET /api/v1/organizations/{org_id}/dashboard (controller_count field). When ENFORCE_ORG_QUOTAS is false, the counts are still tracked - only enforcement is disabled.