This guide walks you through deploying shuul on your own infrastructure. Shuul is designed to run as a Docker container alongside Traefik, protecting your services with WAF filtering and fail2ban-style rate limiting.
- Docker + Docker Compose (or Podman with podman-compose)
- Traefik v3.x running as a reverse proxy
- An OIDC provider (recommended: PocketID)
- MaxMind GeoLite2 City database (optional, for GeoIP features)
- A domain name pointed to your Traefik instance
Add this to your Traefik static configuration (traefik.yml):
experimental:
plugins:
shuul-reporter:
moduleName: github.com/atareao/traefik-shuul-reporter
version: v0.1.0In your Traefik dynamic configuration (or provider), add:
http:
middlewares:
shuul-auth:
forwardAuth:
address: "http://shuul:3000/api/v1/shuul"
trustForwarders: true
shuul-reporter:
plugin:
shuul-reporter:
shuulUrl: "http://shuul:3000/api/v1/report"
timeoutMs: 500
reportClientIP: trueFor each service you want to protect:
http:
routers:
my-app:
rule: "Host(`app.example.com`)"
service: my-app
middlewares:
- shuul-auth # 1st: ForwardAuth (WAF)
- shuul-reporter # 2nd: Status code reporting (Jail)
tls: {}Why this order: The WAF middleware runs first to deny malicious requests before they reach your backend. After your backend responds, the reporter plugin captures the status code and sends it to shuul's Jail pipeline.
Shuul requires an OIDC provider for authentication. PocketID is the recommended option.
| Field | Value |
|---|---|
| Client ID | shuul (or your preference) |
| Client Secret | Generate a random secret |
| Redirect URI | https://shuul.yourdomain.com/api/v1/auth/callback |
| Grant Type | Authorization Code |
| Scopes | openid, profile, email |
Your provider should expose:
https://auth.yourdomain.com/.well-known/openid-configuration
Shuul fetches metadata and JWKS automatically on startup.
GeoIP features require the GeoLite2 City database.
# Download the database
wget -O geo/GeoLite2-City.tar.gz "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=YOUR_KEY&suffix=tar.gz"
tar -xzf geo/GeoLite2-City.tar.gz -C geo/
mv geo/GeoLite2-City_*/GeoLite2-City.mmdb geo/
rm -rf geo/GeoLite2-City_* geo/GeoLite2-City.tar.gzMount the file at the container path specified in MAXMIND_DB_PATH (default: geo/GeoLite2-City.mmdb).
/path/to/shuul/
βββ compose.yml # Docker Compose file
βββ .env # Environment variables
βββ geo/
β βββ GeoLite2-City.mmdb # MaxMind database (optional)
βββ data/
βββ shuul.db # SQLite database (auto-created)
Create a .env file:
# Required
SECRET=generate-a-random-string-with-at-least-32-chars
OIDC_ISSUER_URL=https://auth.yourdomain.com
OIDC_CLIENT_ID=shuul
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URL=https://shuul.yourdomain.com/api/v1/auth/callback
# Optional
DATABASE_URL=sqlite:///app/data/shuul.db?mode=rwc
PORT=3000
MAXMIND_DB_PATH=geo/GeoLite2-City.mmdb
RUST_LOG=infoThe project ships with a compose.yml ready for production:
services:
shuul:
image: atareao/shuul:latest
container_name: shuul
restart: unless-stopped
env_file: .env
volumes:
- ./data:/app/data # SQLite database
- ./geo:/app/geo # MaxMind GeoIP database
networks:
- proxy # Traefik network
healthcheck:
test: curl -f http://localhost:3000/api/v1/health/
interval: 60s
timeout: 5s
retries: 3
labels:
- traefik.enable=true
- traefik.http.routers.shuul.rule=Host(`${FQDN}`)
- traefik.http.routers.shuul.entrypoints=https
- traefik.http.services.shuul.loadbalancer.server.port=3000
networks:
proxy:
external: true- SECRET is a strong random string (
openssl rand -hex 32) - OIDC redirect URL uses HTTPS
- Traefik network is created and shared
- Data directory permissions are correct (container runs as
appuser, UID 10001) - MaxMind database is downloaded and mounted
-
RUST_LOGis set toinfoorwarn(notdebug) in production - Health check is working
- Container restarts automatically
docker compose up -ddocker compose logs -fExpected output:
π Server started successfully
curl https://shuul.yourdomain.com/api/v1/health/
# β "Up and running"Navigate to https://shuul.yourdomain.com in your browser. Click "Sign in with PocketID" and authenticate.
- Go to Templates β WAF Templates
- Search for templates matching your services (WordPress, Nextcloud, etc.)
- Click Apply for each template
- Repeat for Jail Templates
- Go to Rules and verify they're active
Here's a complete example protecting a WordPress instance:
http:
middlewares:
shuul-auth:
forwardAuth:
address: "http://shuul:3000/api/v1/shuul"
shuul-reporter:
plugin:
shuul-reporter:
shuulUrl: "http://shuul:3000/api/v1/report"
timeoutMs: 500
reportClientIP: true
routers:
wordpress:
rule: "Host(`blog.example.com`)"
service: wordpress
middlewares:
- shuul-auth
- shuul-reporter- WAF: WordPress - wp-login, WordPress - xmlrpc, WordPress - wp-admin
- Jail: Auth Brute Force (for wp-login)
- Custom rule: Block countries with no business presence
Set new rules to log_only first, check the logs, then switch to enforce.
docker compose pull
docker compose up -dSQLite migrations run automatically on startup. The database file is never modified in a backward-incompatible way without a migration.
To check the current version:
curl -s https://shuul.yourdomain.com/ | grep -o "Shuul ([0-9.]*)"#!/bin/bash
BACKUP_DIR="/backups/shuul"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
# Backup SQLite database
cp /path/to/shuul/data/shuul.db "$BACKUP_DIR/shuul_$DATE.db"
# Backup environment (exclude secrets if needed)
cp /path/to/shuul/.env "$BACKUP_DIR/env_$DATE.txt"
# Export rules
curl -s -H "Authorization: Bearer $TOKEN" \
https://shuul.yourdomain.com/api/v1/rules/export \
> "$BACKUP_DIR/rules_$DATE.json"
# Keep only last 30 backups
ls -t "$BACKUP_DIR/shuul_*.db" | tail -n +31 | xargs rm -f# Stop shuul
docker compose down
# Restore database
cp /backups/shuul/shuul_20250101_120000.db /path/to/shuul/data/shuul.db
# Restart
docker compose up -dcurl -f http://localhost:3000/api/v1/health/Access the Logs page at /admin/logs for a real-time view of WAF and Jail events. The log viewer requires no SSH access β all events are captured in an in-memory ring buffer and served through the API.
Features:
- 9-column table: timestamp, event type, pipeline, IP, country, rule, path, method, status code
- Auto-refresh: polls every 3 seconds for near real-time updates
- Event filter buttons: dynamically generated based on event types present (block, allow, safe_path, report_ban, etc.)
- Expandable rows: click any row to see the full JSON event payload
- Configurable capacity: change the buffer size at runtime (1000, 5000, 10000, 20000 entries)
- No persistence: buffer is lost on container restart
| Level | Use Case |
|---|---|
error |
Production β only errors |
warn |
Production β errors + warnings |
info |
Default β normal operation info |
debug |
Development β detailed debugging |
trace |
Extreme debugging β all matching details |
Set via RUST_LOG environment variable.
| Pattern | Meaning |
|---|---|
[ALLOW] |
Request passed through WAF |
[BLOCK] |
Request blocked by WAF rule |
[BANNED] |
Request blocked because IP is banned |
[REPORT_BLOCK] |
Jail pipeline counted a failure |
[REPORT_BAN] |
Jail pipeline banned an IP |
[SAFE_PATH] |
Request matched a safe path |
[TRUSTED_IP] |
Request from a trusted IP |
[TRUSTED_UA] |
Request from a trusted user agent |
- Check that your OIDC provider is running
- Verify
OIDC_ISSUER_URLis reachable from the shuul container - Check logs for OIDC initialization:
grep OIDC docker compose logs shuul
- Ensure the rule is
active - Set
modetoenforce(notlog_only) - Check rule weight β lower weight rules run first
- Verify the request matches the rule's filter criteria
- Verify the rule has a
rate_limit_profile_idset - Check the profile's
fail_codesinclude the status code your backend returns - Ensure the
shuul-reporterplugin is in the middleware chain after the backend - Check plugin logs on the Traefik side
SQLite stores data in a single file. If corrupt:
# Stop shuul
docker compose down
# Check integrity
sqlite3 data/shuul.db "PRAGMA integrity_check;"
# Recover if needed (last resort)
sqlite3 data/shuul.db ".clone data/shuul_recovered.db"
# Start fresh (all data lost)
mv data/shuul.db data/shuul.db.bak
docker compose up -dBan format may have changed between versions. The application falls back to an empty ban manager β existing bans are logged as a warning. No data loss occurs.
- Shuul does not rate-limit itself. Traefik's ForwardAuth is synchronous β slow responses from shuul affect all protected services.
- The
SECRETis used for JWT signing. Rotate it periodically. - OIDC tokens expire after 60 minutes. Users are redirected to re-authenticate.
- SQLite is not designed for multi-instance deployments. Run a single shuul container.
- Traefik's
trustForwarders: trueis required for correct IP extraction behind reverse proxies. - The shuul-reporter plugin sends async requests β network issues do not affect backend latency.
services:
shuul:
image: atareao/shuul:latest
container_name: shuul
restart: unless-stopped
env_file: .env
volumes:
- ./data:/app/data:Z # SQLite database
- ./geo:/app/geo:Z # GeoIP database
networks:
- proxy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health/"]
interval: 60s
timeout: 5s
retries: 3
start_period: 10s
labels:
- traefik.enable=true
- traefik.http.routers.shuul.rule=Host(`shuul.example.com`)
- traefik.http.routers.shuul.entrypoints=https
- traefik.http.routers.shuul.tls=true
- traefik.http.services.shuul.loadbalancer.server.port=3000
- traefik.docker.network=proxy
networks:
proxy:
external: true