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.
Org quotas
Section titled “Org quotas”The toggle
Section titled “The toggle”Quota enforcement is controlled by a single environment variable:
ENFORCE_ORG_QUOTAS=false # default - unlimited self-hostedWhen 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.
Quota tiers
Section titled “Quota tiers”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.
| Resource | FREE | STARTER | PROFESSIONAL | ENTERPRISE | UNLIMITED |
|---|---|---|---|---|---|
| Max users | 3 | 10 | 50 | 500 | 999,999 |
| Max admins | 1 | 2 | 10 | 50 | 999,999 |
| Max sites | 1 | 5 | 20 | 100 | 999,999 |
| Max devices (org-wide) | 10 | 100 | 500 | 5,000 | 999,999 |
| Max devices per site | 10 | 50 | 100 | 500 | 999,999 |
| Max controllers | 1 | 10 | 50 | 200 | 999,999 |
| API rate limit (req/min) | 100 | 500 | 2,000 | 10,000 | 999,999 |
| Max API keys ¹ | 1 | 5 | 20 | 50 | 50 |
| Audit retention (days) | 7 | 30 | 90 | 365 | 9,999 |
| Metric retention (days) | 1 | 7 | 30 | 90 | 9,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 flag | FREE | STARTER | PROFESSIONAL | ENTERPRISE / UNLIMITED |
|---|---|---|---|---|
basic_monitoring | yes | yes | yes | yes |
webhooks | - | yes | yes | yes |
automation | - | yes | yes | yes |
backup | - | - | yes | yes |
api_access | - | - | yes | yes |
| All features | - | - | - | yes |
What is enforced (and where)
Section titled “What is enforced (and where)”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:
| Action | Quota resource checked |
|---|---|
| Adopt a single device | devices |
| Batch-adopt devices | devices (whole-batch atomic check: increment = batch size) |
| Create a controller | controllers |
| Add an org member | users |
| Promote a member to admin | admins |
| Create a site | sites |
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.
Org-level endpoints
Section titled “Org-level endpoints”Auth requirements differ per endpoint:
| Method | Path | Who 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}/dashboard | SUPER_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.
Deployment tiers
Section titled “Deployment tiers”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.
| Tier | Target | Key additions |
|---|---|---|
| Lite | Homelab / single node | 1 API worker, no extras |
| Pro | SMB | io-worker profile + monitoring (Flower) |
| Max | Enterprise | pooling (PgBouncer) + dr (off-site backup) + HA overlay |
| Dev | Development | Hot-reload, debug middleware |
Start commands:
# Litedocker compose --env-file .env.lite up -d
# Prodocker compose --env-file .env.pro up -d
# Maxdocker compose --env-file .env.max up -d
# Devdocker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.dev up -dOr use the one-command installer, which picks the right env file automatically:
./install.sh --tier pro --domain sdn.example.comAlways-on core services
Section titled “Always-on core services”These run in every tier:
postgres- PostgreSQL 18.4 primarylogdb- TimescaleDB (metrics / events / heartbeats / time-series)redis- Valkey 8.1 (cache / broker / results;redis://URLs retained for compatibility)api- FastAPI/gunicorn API workersworker- Celery workerscheduler- Celery beat (periodic tasks)pg-backup- GPG-encrypted DB backup with 7-day prunecaddy- Edge proxy with automatic HTTPS
The data tier is internal-only. Only the Caddy edge publishes host ports.
Optional profiles
Section titled “Optional profiles”Enable with COMPOSE_PROFILES=<name> in your env file or by passing --profile to docker compose:
| Profile | What it adds | Recommended for |
|---|---|---|
io-worker | Dedicated I/O-bound Celery worker | Pro / Max |
monitoring | Flower dashboard | Pro / Max |
cameras | go2rtc restream | Deployments using cameras |
pooling | PgBouncer connection pooler | Max / high concurrency |
dr | rclone off-site backup | Max / disaster-recovery |
metrics | Prometheus + exporters | Max / observability |
nginx | nginx edge escape-hatch | When Caddy is not suitable |
Connection limits and pooling
Section titled “Connection limits and pooling”PostgreSQL pool (API workers)
Section titled “PostgreSQL pool (API workers)”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.
Valkey (Celery broker + cache)
Section titled “Valkey (Celery broker + cache)”The default stack runs a single Valkey master. The Max tier adds:
- 1 Valkey replica
- 3 Valkey Sentinels (authentication +
resolve-hostnamesenabled)
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:
docker compose -f docker-compose.yml -f docker-compose.ha.yml --env-file .env.max up -dRate limiting
Section titled “Rate limiting”The API enforces rate limits at the edge, applied per-principal:
| Category | Limit (default) |
|---|---|
| General requests | 100 / minute (RATE_LIMIT_DEFAULT) |
| Authentication endpoints | 5 / minute (RATE_LIMIT_AUTH) |
| Global per-principal ceiling | 600 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.
Audit and metric retention
Section titled “Audit and metric retention”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}.
Sizing guidance
Section titled “Sizing guidance”The following guidance is based on testing against the live codebase. It is not a vendor benchmark.
| Devices managed | Suggested tier | Workers | Notes |
|---|---|---|---|
| < 50 | Lite | 1 | Single-box homelab |
| 50-500 | Pro | 2-4 | io-worker + monitoring recommended |
| 500-5,000 | Max | 4-8 | PgBouncer + HA overlay + io-worker |
| > 5,000 | Max + custom | scale horizontally | Contact 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.
Relevant environment variables
Section titled “Relevant environment variables”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.
| Variable | Default | Purpose |
|---|---|---|
ENFORCE_ORG_QUOTAS | false | Enable per-org tier quota enforcement |
PUBLIC_BASE_URL | http://localhost:8000 | External base URL for notification action links and agent WebSocket |
WEB_CONCURRENCY | tier-dependent | Number of gunicorn workers |
API_MEM_LIMIT | tier-dependent | Memory 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 |
Checking your current quota status
Section titled “Checking your current quota status”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.
Next steps
Section titled “Next steps”- Multi-Tenancy & MSP - org hierarchy, per-user site grants, and the RBAC model
- Enterprise Overview - full feature index for the Enterprise module
- SLA Management - define retention and availability thresholds per tier
- Deployment guide - env files, profiles, and the HA overlay in full detail