Production-ready FreeRADIUS deployment with a SQL backend. Supports Docker Compose, Kubernetes (raw manifests), and Helm.
This stack is the AAA / RADIUS control plane for an ISP / broadband network. It authenticates and accounts for subscribers (PPPoE, IPoE, Hotspot) that terminate on your NAS / BNG devices — MikroTik, accel-ppp, Cisco, Juniper, etc. Concretely it provides:
- Authentication —
Access-Requestfrom the NAS is checked against theradcheck/radusergrouptables (per-user credentials, group/plan attributes). - Authorization — reply attributes (rate-limit, IP/pool, VLAN) returned to the NAS.
- Accounting — session Start/Interim/Stop written to
radacctfor usage/quota. - CoA / Disconnect — the NAS is the CoA target (UDP 3799); a management layer
(e.g.
freeradius-api) sends Disconnect-Message / Change-of-Authorization to kick, suspend, or change a subscriber's plan live.
It is the data/auth tier — it does not terminate PPPoE itself. The NAS/BNG (e.g. accel-ppp on bare metal) does that and talks RADIUS to this stack.
Subscribers (PPPoE/IPoE)
│
▼
NAS / BNG (MikroTik · accel-ppp · Cisco · Juniper)
│ RADIUS auth/acct (UDP 1812/1813) ▲ CoA/Disconnect (UDP 3799)
▼ │
┌─────────────────────────────────────────────────────────┐
│ FreeRADIUS (this stack, N stateless replicas) │
│ - externalTrafficPolicy: Local → preserves NAS source IP │
│ so per-NAS secrets in the `nas` table match │
└───────────────┬───────────────────────┬───────────────────┘
│ SQL │ Redis (optional)
▼ ▼
MariaDB / MySQL Redis (Interim-Update
(radius DB: radcheck, buffer; drained to SQL
radacct, nas, …) by an external worker)
▲
│ reads/writes (users, plans, sessions) + sends CoA to the NAS
┌─────────┴─────────┐
│ freeradius-api │ management API / billing integration
│ (separate repo) │ + acctflush worker (Redis → radacct)
└────────────────────┘
Database options
| Mode | When | How |
|---|---|---|
| Bundled single DB | dev / small / single-node | default (mysql.enabled=true, or the raw examples/kubernetes/ manifests) |
| External HA cluster | production | point FreeRADIUS at an external MariaDB Galera (or MySQL InnoDB Cluster). See examples/kubernetes/mariadb-galera/ and examples/helm/freeradius/values-production.yaml |
The bundled DB is a single pod = single point of failure — fine for development,
but production must use an external synchronous HA cluster. A 3-node Galera tolerates
losing one node (quorum 2/3) and exposes one stable primary endpoint that FreeRADIUS
(externalMysql.host) connects to.
Components
| Component | Role |
|---|---|
| FreeRADIUS 3.2.8 | RADIUS auth/acct server (stateless, scale horizontally) |
| MariaDB Galera / MySQL | radius database (users, groups, NAS clients, accounting) |
| Redis 7 (optional) | Interim-Update accounting buffer (spares the DB at scale) |
⚠️ Secrets never live in this repo. RADIUS secret, DB passwords, and backup keys are supplied at deploy time via Kubernetes Secrets /--set/ a vault — see Security. This is a public repository; do not commit real credentials, internal IPs, or hostnames.
- FreeRADIUS 3.2.8 with MySQL/MariaDB backend (Debian Trixie base, FreeRADIUS from sid)
- Multi-platform Docker images (linux/amd64, linux/arm64)
- Multiple deployment options: Docker Compose, Kubernetes manifests, Helm chart
- High Availability ready with external MySQL cluster support
- Auto-initialization of database schema
- Configurable RADIUS clients via environment variables
- Health checks for container orchestration
- Log rotation to prevent disk bloat
- CI/CD with GitHub Actions
- Security hardened containers (non-root, capability drop, no-new-privileges)
- NetworkPolicy for pod traffic isolation
- MySQL TLS support for encrypted database connections
- Backup encryption with GPG
- Debug mode via environment variable
- MariaDB support as alternative database
- Vulnerability scanning in CI pipeline
| Registry | Image |
|---|---|
| Docker Hub | cepatkilatteknologi/freeradius:3.2.8 |
| GHCR | ghcr.io/cepat-kilat-teknologi/freeradius:3.2.8 |
cd examples/docker
# Create environment file
cp .env.example .env
# Edit .env - change all CHANGE_ME_* values
nano .env
# Start services
make pull && make up
# Check status
make status
# Add test user and test
make add-test-user
make test-auth# Edit secrets first
nano examples/kubernetes/secret.yaml
# Apply manifests
make k8s-apply
# Check status
make k8s-status# For local development (faster startup ~90s)
helm install freeradius examples/helm/freeradius \
-f examples/helm/freeradius/values-local.yaml \
--namespace freeradius \
--create-namespace
# For production with bundled MySQL
helm install freeradius examples/helm/freeradius \
--namespace freeradius \
--create-namespace \
--set freeradius.secret=YOUR_RADIUS_SECRET \
--set mysql.rootPassword=YOUR_ROOT_PASSWORD \
--set mysql.password=YOUR_DB_PASSWORD
# Or with external MySQL cluster
helm install freeradius examples/helm/freeradius \
--namespace freeradius \
--create-namespace \
--set mysql.enabled=false \
--set externalMysql.host=mysql-cluster.example.com \
--set externalMysql.password=YOUR_DB_PASSWORD \
--set freeradius.secret=YOUR_RADIUS_SECRETexistingSecretsupport for external secret management (e.g., Sealed Secrets, External Secrets Operator)- HPA (autoscaling) support for automatic replica scaling based on CPU/memory
- ServiceMonitor for Prometheus metrics collection
- NetworkPolicy template for pod traffic isolation
- Helm tests -- validate deployment with
helm test freeradius imagePullSecretsfor pulling from private container registries
freeradius-stack/
├── Dockerfile # FreeRADIUS image
├── Makefile # Root commands
├── .editorconfig # Editor settings
├── .gitattributes # Git file handling
├── CODE_OF_CONDUCT.md # Contributor Covenant 2.1
├── CONTRIBUTING.md # Contribution guidelines
├── SECURITY.md # Security policy
├── CHANGELOG.md # Release notes
├── scripts/
│ ├── entrypoint.sh # Container entrypoint
│ └── setup-gh-pages.sh # Helm repo setup
├── .github/
│ ├── CODEOWNERS # Code ownership
│ ├── pull_request_template.md # PR template
│ └── workflows/
│ ├── ci.yml # Build & push images
│ └── helm.yml # Publish Helm chart
└── examples/
├── docker/ # Docker Compose
│ ├── docker-compose.yaml # Production
│ ├── docker-compose.dev.yaml # Local dev (builds from source)
│ ├── .env.example
│ └── Makefile
├── kubernetes/ # K8s manifests
│ ├── namespace.yaml
│ ├── secret.yaml
│ ├── configmap.yaml
│ ├── serviceaccount.yaml
│ ├── rbac.yaml
│ ├── mysql-statefulset.yaml
│ ├── freeradius-deployment.yaml
│ ├── backup-cronjob.yaml
│ ├── networkpolicy.yaml
│ ├── pdb.yaml
│ └── kustomization.yaml
└── helm/freeradius/ # Helm chart
├── Chart.yaml
├── .helmignore
├── values.yaml # Production values
├── values-local.yaml # Local development (faster)
└── templates/
├── NOTES.txt
├── hpa.yaml
├── role.yaml
├── rolebinding.yaml
├── networkpolicy.yaml
├── servicemonitor.yaml
└── tests/
| Variable | Description | Required | Default |
|---|---|---|---|
MYSQL_HOST |
MySQL server hostname | Yes | - |
MYSQL_PORT |
MySQL server port (numeric only) | Yes | - |
MYSQL_USER |
MySQL username | Yes | - |
MYSQL_PASSWORD |
MySQL password | Yes | - |
MYSQL_DBNAME |
MySQL database name (alphanumeric + underscore only) | Yes | - |
RADIUS_SECRET |
RADIUS shared secret (special characters supported) | Yes | - |
RADIUS_CLIENTS |
Additional clients (comma-separated CIDR, validated) | No | - |
TZ |
Timezone (e.g., Asia/Jakarta) |
No | UTC |
DO_NOT_IMPORT_DB |
Skip DB schema import if set | No | - |
HEALTHCHECK_SECRET |
Secret for internal health checks (localhost only) | No | testing123 |
RADIUS_ALLOW_PRIVATE_NETWORKS |
Add RFC 1918 ranges as RADIUS clients | No | true |
RADIUS_DEBUG |
Enable FreeRADIUS debug mode (-X) | No | - |
MYSQL_TLS_CA |
Path to MySQL CA certificate | No | - |
MYSQL_TLS_CERT |
Path to MySQL client certificate | No | - |
MYSQL_TLS_KEY |
Path to MySQL client key | No | - |
BACKUP_ENCRYPT_KEY |
GPG passphrase for backup encryption | No | - |
DB_IMAGE |
Database image (Docker Compose) | No | mysql:8.4 |
FREERADIUS_IMAGE |
FreeRADIUS image override (Docker Compose) | No | cepatkilatteknologi/freeradius:3.2.8 |
Via environment variable:
RADIUS_CLIENTS=10.0.0.0/8,192.168.0.0/16,203.0.113.50/32Via database (NAS table):
INSERT INTO nas (nasname, shortname, secret, description)
VALUES ('192.168.1.1', 'router1', 'secret123', 'Main Router');
-- Simple user with password
INSERT INTO radcheck (username, attribute, op, value)
VALUES ('john', 'Cleartext-Password', ':=', 'password123');
-- User with VLAN assignment
INSERT INTO radreply (username, attribute, op, value)
VALUES ('john', 'Tunnel-Type', ':=', 'VLAN'),
('john', 'Tunnel-Medium-Type', ':=', 'IEEE-802'),
('john', 'Tunnel-Private-Group-ID', ':=', '100');
| Port | Protocol | Description |
|---|---|---|
| 1812 | UDP | RADIUS Authentication |
| 1813 | UDP | RADIUS Accounting |
| 18121 | UDP | Status Server (localhost only, not exposed in Dockerfile) |
make help # Show all commands
# Image
make build # Build Docker image
make push REGISTRY=x # Push to registry
make build-multiarch # Build multi-arch image (amd64+arm64)
make push-multiarch # Build and push multi-arch image
# Docker Compose
make docker-up # Start services
make docker-down # Stop services
make docker-logs # View logs
# Kubernetes
make k8s-apply # Apply manifests
make k8s-delete # Delete resources
make k8s-status # Show status
# Helm
make helm-install # Install chart
make helm-upgrade # Upgrade release
make helm-uninstall # Uninstall
make helm-template # Render templatesmake init-env # Create .env file
make pull # Pull images
make up # Start services
make down # Stop services
make logs # View logs
make status # Show status
make mysql # Connect to MySQL
make add-test-user # Add test user
make test-auth # Test authentication
make backup # Backup database
make restore FILE=x # Restore from backup
make list-backups # List available backups
make clean # Remove everything# Using radtest (install freeradius-utils)
radtest username password localhost 0 YOUR_SECRET
# From container
docker exec freeradius sh -c 'echo "User-Name=testuser,User-Password=testpass" | radclient 127.0.0.1:1812 auth testing123'# From container (uses HEALTHCHECK_SECRET environment variable)
docker exec freeradius sh -c \
'echo "Message-Authenticator = 0x00" | radclient 127.0.0.1:18121 status $HEALTHCHECK_SECRET'cd examples/docker
# Create a timestamped backup
make backup
# Creates: backups/radius_YYYYMMDD_HHMMSS.sql.gz
# Create an encrypted backup (set BACKUP_ENCRYPT_KEY in .env)
BACKUP_ENCRYPT_KEY=my-secret-passphrase make backup
# Creates: backups/radius_YYYYMMDD_HHMMSS.sql.gz.gpg
# List available backups
make list-backups# Restore a specific backup (use --force for non-interactive mode)
make restore FILE=backups/radius_20260127_120000.sql.gz
make restore FILE=backups/radius_20260127_120000.sql.gz --force
# Verify restore was successful
make test-auth# Backup
docker compose exec -T db mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" \
--single-transaction --routines --triggers radius | gzip > backup.sql.gz
# Restore
gunzip -c backup.sql.gz | docker compose exec -T db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" radiusWhen ACCT_REDIS_ENABLED=true, FreeRADIUS routes Interim-Update packets to a
Redis list (radius:acct:interim) instead of writing them straight to MySQL,
which spares the database from high-frequency accounting writes at scale (e.g.
thousands of PPPoE subscribers sending interim updates every few minutes).
Accounting-Start and Accounting-Stop always go directly to SQL so session
boundaries remain durable.
⚠️ Required external consumer. This stack only produces to Redis. The buffer is drained back into theradaccttable by theacctflushworker in freeradius-api (internal/acctflush/worker.go). If you enableACCT_REDIS_ENABLED=truewithout running that worker, interim data accumulates in Redis and never reaches MySQL — live bandwidth/quota/usage figures will be silently stale even though authentication and session start/stop keep working.
- Run the freeradius-api
acctflushworker and confirm it is consumingradius:acct:interim. - Monitor the queue depth: alert if it keeps growing (worker down or lagging):
redis-cli LLEN radius:acct:interim
- Size Redis memory for your subscriber count. The bundled Redis is capped at
maxmemory 128mbwithnoeviction— correct policy (fail the write rather than drop accounting data), but at thousands of users a stalled worker can fill it, after whichRPUSHfails and interim deltas are lost for that window (Start/Stop stay safe in SQL). Increasemaxmemoryand keepnoeviction. - Known runtime trade-off: if Redis goes down after startup, interim updates
have no SQL fallback (the unlang routes interim only to Redis). freeradius-api's
radacctusecase already degrades gracefully — live sessions just won't include unflushed interim data until Redis returns. Start/Stop remain durable. - Production: prefer an external HA Redis (Sentinel) over the bundled single
pod (
redis.enabled=false+externalRedis.host=...).
The RADIUS LoadBalancer Service sets externalTrafficPolicy: Local (Helm
default; also in the raw examples/kubernetes/ Service). This preserves the real
NAS/BNG source IP so per-NAS secrets in the SQL nas table match and FreeRADIUS
can identify which NAS each request came from. With the Kubernetes default
(Cluster), kube-proxy SNATs the packet and FreeRADIUS sees the node IP — every
request then collapses onto the broad fallback client + single shared secret, and
per-NAS secrets become impossible.
Caveat:
Localonly routes external traffic to nodes that are running a FreeRADIUS pod. KeepreplicaCount≥ the number of LB-advertising nodes and rely on the pod anti-affinity / topology spread so every receiving node has a pod.
For production, use an external MySQL cluster:
Docker Compose:
# Remove db service, update freeradius environment
environment:
MYSQL_HOST: mysql-cluster.example.com
MYSQL_PORT: 3306Helm:
helm install freeradius examples/helm/freeradius \
--set mysql.enabled=false \
--set externalMysql.host=mysql-cluster.example.com \
--set externalMysql.password=PASSWORDHelm chart supports multiple replicas:
freeradius:
replicaCount: 3All replicas share the same MySQL database for user/NAS data.
- PodDisruptionBudget ensures minimum 1 replica remains available during maintenance or node drains
- topologySpreadConstraints spread pods across nodes for fault tolerance
- HPA (Horizontal Pod Autoscaler) for automatic scaling based on CPU/memory utilization
| Workflow | Trigger | Description |
|---|---|---|
ci.yml |
Push to main, tags, PRs | Build, test, scan & push Docker images |
helm.yml |
Changes to examples/helm/ |
Publish Helm chart |
The CI pipeline includes Trivy vulnerability scanning, an automated test job, and FreeRADIUS version auto-detection from the built image.
| Secret | Description |
|---|---|
DOCKERHUB_USERNAME |
Docker Hub username |
DOCKERHUB_TOKEN |
Docker Hub access token |
After CI runs, images are available at:
- Docker Hub:
docker.io/cepatkilatteknologi/freeradius:VERSION - GHCR:
ghcr.io/cepat-kilat-teknologi/freeradius:VERSION
After setting up GitHub Pages:
helm repo add freeradius https://cepat-kilat-teknologi.github.io/freeradius-stack
helm repo update
helm install freeradius freeradius/freeradiusEnable debug mode via the RADIUS_DEBUG environment variable to start FreeRADIUS with full debug output (-X):
# Enable debug mode with docker run
docker run -e RADIUS_DEBUG=1 cepatkilatteknologi/freeradius:3.2.8
# Or in docker-compose, add to .env:
RADIUS_DEBUG=1# Check logs
docker logs freeradius
# Run in debug mode
docker exec -it freeradius freeradius -X# Verify MySQL is running
docker exec freeradius mysql --skip-ssl -h db -u $MYSQL_USER -p$MYSQL_PASSWORD -e "SELECT 1"
# Check environment variables
docker exec freeradius env | grep MYSQL# Check user exists
docker exec radius-mysql mysql -u radius -p$MYSQL_PASSWORD radius \
-e "SELECT * FROM radcheck WHERE username='testuser';"
# Check RADIUS debug
docker exec freeradius freeradius -X
# Then try authentication and watch the outputIf your RADIUS_SECRET contains special characters (e.g., /, +, = from base64), ensure you're using the latest image version. Earlier versions had a bug where special characters were incorrectly escaped.
# Verify the secret in FreeRADIUS config matches your RADIUS_SECRET
docker exec freeradius grep -A2 "client container-networks" /etc/freeradius/3.0/sites-enabled/status
# The secret should match RADIUS_SECRET exactly, without backslash escaping# Test status server manually
docker exec freeradius sh -c 'echo "Message-Authenticator = 0x00" | radclient -t 3 127.0.0.1:18121 status $HEALTHCHECK_SECRET'-
Change all default secrets - All
CHANGE_ME_*values MUST be replaced with strong, randomly generated secrets:# Generate secure passwords openssl rand -base64 32 -
CHANGE_ME_ values are rejected* -- the entrypoint will fail on startup if any placeholder
CHANGE_ME_*values remain in the configuration -
Use strong passwords - MySQL and RADIUS secrets should be at least 32 characters. Special characters (including
/,+,=from base64) are fully supported -
Validate input - The entrypoint script validates:
MYSQL_DBNAME: Only alphanumeric and underscore allowedMYSQL_PORT: Must be numericRADIUS_CLIENTS: Must be valid CIDR notation
-
Restrict network access to RADIUS ports (1812, 1813):
- Use firewall rules to allow only trusted NAS devices
- For Kubernetes, use NetworkPolicies
- For cloud LoadBalancers, use internal LB annotations:
# GKE annotations: networking.gke.io/load-balancer-type: "Internal"
# AWS annotations: service.beta.kubernetes.io/aws-load-balancer-internal: "true"
-
NetworkPolicy restricts MySQL access to FreeRADIUS and backup pods only
-
Pod Security Standards labels enforce baseline policy on the namespace
-
Status server (18121) is bound to localhost by default and is no longer exposed in the Dockerfile -- used only for internal health checks
-
MySQL TLS supported for encrypted database traffic -- configure via
MYSQL_TLS_CA,MYSQL_TLS_CERT, andMYSQL_TLS_KEYenvironment variables -
Backup encryption available via
BACKUP_ENCRYPT_KEYfor GPG-encrypted backups -
Backup security - Backup files contain sensitive data, secure the backup storage
-
Container runs as non-root (freerad user via gosu)
-
Container capabilities are dropped (only SETUID, SETGID, NET_BIND_SERVICE retained)
-
Security contexts - Helm chart includes:
- Pod anti-affinity for high availability
- Dropped capabilities (only SETUID, SETGID, NET_BIND_SERVICE)
- No privilege escalation (
no-new-privileges)
-
Regular updates - Rebuild images periodically for security patches
MIT License - see LICENSE for details.
- Fork the repository
- Create a feature branch
- Make your changes
- Run tests
- Submit a pull request
This project is a deployment stack built around FreeRADIUS, the open-source RADIUS server maintained by the FreeRADIUS project and InkBridge Networks. All credit for the RADIUS server itself goes to the FreeRADIUS team and its contributors.