All apps · 0 apps
Only-Fan-Controller
Docker app from Seth_J's Repository
Overview
Readme
View on GitHub🌀 Only Fan Controller
The hottest fan controller for your server
An intelligent fan controller for Dell PowerEdge servers with simple threshold-based control, hysteresis to prevent oscillation, and a workload hints API for proactive cooling.
Features
- Multi-source Monitoring — CPU temps via IPMI, GPU temps via nvidia-smi
- Simple Threshold Control — Set CPU and GPU temperature thresholds, fans increase when exceeded
- Hysteresis — Configurable cooldown delay prevents fan oscillation at threshold boundaries
- Workload Hints API — External scripts can signal upcoming load for proactive cooling
- Web Dashboard — Real-time temps, fan speeds, and threshold visualization
- Constant Idle Speed — Quiet operation when temps are below thresholds
Quick Start
Docker (Recommended)
# Clone the repo
git clone https://github.com/sethpjohnson/only-fan-controller
cd only-fan-controller
# Copy and edit config
cp config.example.yaml config.yaml
nano config.yaml
# Run with docker-compose
docker-compose up -d
Docker with NVIDIA GPU Support
docker run -d \
--name only-fan-controller \
--restart unless-stopped \
--gpus all \
-e NVIDIA_VISIBLE_DEVICES=all \
-p 8086:8086 \
-v ./config.yaml:/etc/only-fan-controller/config.yaml:ro \
-v ./data:/var/lib/only-fan-controller \
only-fan-controller:latest
How It Works
- Idle State: Fans run at
idle_speed(default 20%) when both CPU and GPU are below their thresholds - Threshold Exceeded: When CPU >
cpu_thresholdOR GPU >gpu_threshold, fans increase bystep_sizeper 5°C over - Cooldown: Fans only ramp down after staying below thresholds for
cooldown_delayseconds (prevents oscillation) - Workload Hints: External scripts can set a minimum fan speed floor via the API
Web Dashboard
Access the dashboard at http://your-server:8086/dashboard/
Shows:
- CPU temperatures (all cores)
- GPU temperatures (all GPUs)
- Current fan speed and mode
- Temperature history graph with threshold lines
- Active workload hints
Safety
While running, this controller puts the iDRAC into manual fan mode via raw IPMI OEM commands and is the sole thing standing between your hardware and the BMC's fans idling. It is designed so that as many failure modes as possible fail toward "the BMC keeps cooling the box," not toward "the fans stay wherever they were and the machine cooks":
- Abnormal exit restores auto mode. SIGINT/SIGTERM, a fatal API server
error, and even a panic in the control loop all go through the same
shutdown path, which calls
RestoreAutoModebefore the process exits. A config that exists but fails safety validation makes the process refuse to start at all (see Upgrading) rather than run with unsafe defaults — since manual mode is never enabled in that case, the BMC simply keeps its own automatic control. - Sensor loss fails up, not down. A failed temperature read is never
treated as 0°C (which would ramp fans down). The controller holds the last
fan speed and, after
sensor_failure_limitconsecutive failures, hands cooling back to BMC auto mode. This is recoverable: once sensor reads succeed again, manual control is reclaimed automatically. - Fan-write failure is a sticky fail-safe. After
write_failure_limitconsecutive failures to write a fan speed, control is handed back to BMC auto mode and stays there until the process restarts — the controller does not probe the write channel again mid-run, since that would flap the BMC between manual and auto. - Restore is retried until confirmed. If the
RestoreAutoModecall itself fails (e.g. the BMC is unreachable), the controller keeps retrying on every control-loop tick until it succeeds;restore_pendingin/api/statusreflects this. Nothing re-enables manual mode while a restore is unconfirmed. - Critical temperatures bypass everything. If
critical_cpu_temporcritical_gpu_tempis reached, fans jump straight tomax_speed, bypassing step ramping and any active manual override.
What this does not protect against: a SIGKILL (kill -9) or a power
loss bypasses the shutdown path entirely and cannot trigger a restore — the
BMC's own thermal protections are the last line of defense in that case, same
as on any other server. This controller is a cooling policy on top of the
BMC, not a replacement for its built-in safety logic.
Also note that the raw IPMI commands used here (ipmitool raw 0x30 0x30 ...)
are Dell iDRAC-specific OEM commands — they are not portable to other
vendors' BMCs.
API Security
The API binds 0.0.0.0 by default (required for container/bridge networking), so
the dashboard and API are reachable from every host on your LAN. Fan control is
protected by a bearer token, not by the bind address.
- Mutating endpoints —
POST/DELETE /api/overrideandPOST /api/hint,DELETE /api/hint/:source— require the token. - Read-only endpoints —
/api/status,/api/history,/api/config, and the dashboard — stay open.
Set the token via api.token in the config (or the API_TOKEN env var), then
send it as an Authorization: Bearer <token> header:
curl -X POST http://localhost:8086/api/override \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"speed": 50, "duration": 300, "reason": "testing"}'
scripts/hint-client.sh reads API_TOKEN from the environment and adds the
header automatically.
If no token is configured, mutating endpoints are accepted only from the
local host (loopback) and a warning is logged at startup. This keeps a
single-host setup convenient without silently exposing fan control to the LAN.
Loopback is determined from the real connection peer — a spoofed
X-Forwarded-For cannot bypass it.
There is no built-in TLS. To expose the controller beyond a trusted LAN, run it behind a reverse proxy (nginx / Caddy / Traefik) that terminates TLS.
Upgrading from an earlier version? No breaking changes: add
api.tokento your config (or setAPI_TOKEN) to enable off-host control. Existing docker-compose / Unraid deployments keep working unchanged — without a token they simply restrict control to loopback.
Other safety behavior worth knowing:
- Manual overrides are clamped to the configured
min_speed/max_speedband and capped at 24 hours (an override withduration: 0is treated as 24h, never truly indefinite). A critical temperature still ramps fans to max, overriding any manual override. - Hint
source/typeare restricted to[A-Za-z0-9_.-](max 64 chars),intensitytolow/medium/high, andactiontostart/stop; everything else is rejected with400.
Home Assistant (MQTT)
Optional. Off by default. When enabled, the controller connects to your MQTT broker, self-registers in Home Assistant via MQTT Discovery, publishes its state every control tick, and accepts fan overrides and workload hints over MQTT — full parity with the HTTP API.
Enable it in config.yaml:
mqtt:
enabled: true
broker: "tcp://192.168.1.10:1883"
username: "homeassistant"
password: "your-broker-password"
client_id: "only-fan-controller"
base_topic: "only-fan-controller"
discovery_prefix: "homeassistant"
Or via environment variables: MQTT_ENABLED=true, MQTT_BROKER=tcp://…,
MQTT_USERNAME=…, MQTT_PASSWORD=….
What appears in Home Assistant
All entities are grouped under one device, Only Fan Controller:
| Entity | Type | Notes |
|---|---|---|
| CPU Temperature | sensor | °C |
| GPU Temperature (Max) | sensor | °C; hottest card across all GPUs (drives fan logic) |
| GPU N (model) Temperature / Utilization / Power | sensor | one set per detected GPU (°C / % / W) |
| Fan Speed, Target Fan Speed | sensor | % |
| Thermal Zone, Failsafe Reason | sensor | text |
| Failsafe Active, Restore Pending, Last Fan Write Failed | binary_sensor | problem class |
| Override Fan Speed | number | slider bound to min_speed/max_speed; sends a 1-hour override |
| Clear Fan Override | button | clears any active override |
Per-GPU sensors are dynamic in card count. On a two-GPU box you get two sets of Temperature/Utilization/Power sensors, on a three-GPU box three, and so on. Each card is announced as soon as the controller's first GPU read completes (a few seconds after start — MQTT connects before the sensors are read, so the per-card entities appear on the first control tick, not instantly). A GPU added later is picked up automatically on the next tick. If the card count shrinks, the stale per-card entities linger in Home Assistant as unavailable until you delete them (the controller does not remove discovery entries).
The device is marked unavailable the instant the process dies — the broker
publishes the Last Will (offline) on ungraceful disconnect, giving you free
external monitoring for the fail-safe scenarios.
broker is forgiving about format: a bare host, host:port, or even a browser
URL pasted from Home Assistant (http://ha.local:8123) is normalized to
tcp://host:port at startup (the interpretation is logged). If the broker turns
out to be unreachable — e.g. you pointed it at HA's web UI port 8123 instead of
the MQTT broker's 1883 — the log calls that out explicitly instead of silently
timing out.
Topics
only-fan-controller/availability— retainedonline/offline.only-fan-controller/state— retained JSON, one document per control tick.only-fan-controller/cmd/override—{"speed": 60, "duration_seconds": 3600, "reason": "..."}only-fan-controller/cmd/override/clear— any payload clears the override.only-fan-controller/cmd/hint—{"type": "transcode", "action": "start|stop", "intensity": "high", "source": "plex", "duration_estimate": 120}
Commands go through the exact same safety clamps and validation as the HTTP API:
speed is clamped to min_speed/max_speed, override duration is capped at 24h,
the critical-temperature ramp overrides everything, and hint fields are charset/
length checked. A malformed command is logged and dropped, never applied.
Command topics must be published without the retain flag — a retained
command would be replayed by the broker on every reconnect and restart. The
bridge deliberately drops retained messages on the command topics (logging the
drop once per topic) so a stray mosquitto_pub -r or a misconfigured automation
cannot silently re-fire the fans forever.
Hints have no dedicated HA entity (a source/type/intensity tuple doesn't map to
one) — drive cmd/hint from an HA automation for the full-parity escape hatch.
Security
MQTT command authorization is your broker's authentication. The HTTP bearer token is HTTP-only and does not apply here — anyone who can publish to the broker can command the fans, so protect it with broker credentials/ACLs. The blast radius is still bounded by the controller-level clamps and the critical-temp override. There is no TLS in v1; keep the broker on a trusted LAN or in front of a TLS-terminating proxy.
API Endpoints
GET /api/status
Returns current system state including thresholds:
{
"cpu": { "Temps": [33, 34], "Max": 34 },
"gpu": { "Devices": [{"name": "Tesla P40", "temp": 42}], "Max": 42 },
"current_speed": 20,
"zone": "idle",
"cpu_threshold": 65,
"gpu_threshold": 60,
"idle_speed": 20,
"failsafe_active": false,
"failsafe_reason": "none",
"restore_pending": false,
"last_write_failed": false
}
The fail-safe fields (see Safety above):
failsafe_active—trueonce cooling has been handed back to BMC auto mode (sensor loss or repeated fan-write failures).failsafe_reason—"none","sensor-loss", or"write-failure".restore_pending—trueif fail-safe is active but the hand-back to BMC auto mode has not yet been confirmed (the BMC may still be in manual mode); the controller keeps retrying until this clears.last_write_failed—trueif the most recent fan-speed write failed.
POST /api/hint
Register a workload hint for proactive cooling:
# Signal high GPU load starting (sets minimum fan speed to 45%)
curl -X POST http://localhost:8086/api/hint \
-H "Content-Type: application/json" \
-d '{
"type": "gpu_load",
"action": "start",
"intensity": "high",
"source": "whisper-transcription"
}'
# Signal work complete (removes the floor)
curl -X POST http://localhost:8086/api/hint \
-H "Content-Type: application/json" \
-d '{
"type": "gpu_load",
"action": "stop",
"source": "whisper-transcription"
}'
Intensity levels:
low— 25% minimum fan speedmedium— 35% minimum fan speedhigh— 45% minimum fan speed
POST /api/override
Set a manual fan speed override:
curl -X POST http://localhost:8086/api/override \
-H "Content-Type: application/json" \
-d '{"speed": 50, "duration": 300, "reason": "testing"}'
DELETE /api/override
Clear manual override and return to automatic control.
GET /api/history?duration=3600
Get temperature/fan history for graphing.
Configuration
See config.example.yaml for all options.
Logging: the controller logs to stderr only — there is no logging.level
or logging.file config. Under Docker, use docker logs and your Docker log
driver's rotation settings (e.g. json-file with max-size/max-file) to
capture and rotate logs.
Key Settings
fan_control:
idle_speed: 20 # Base fan speed when cool (%)
cpu_threshold: 65 # Increase fans when CPU exceeds this (°C)
gpu_threshold: 60 # Increase fans when GPU exceeds this (°C)
step_size: 10 # Fan increase per 5°C over threshold (%)
cooldown_delay: 60 # Seconds below threshold before ramping down
# Emergency ramp trigger — required, must exceed the (effective) threshold
# above it or the service refuses to start. See Safety and Upgrading above.
critical_cpu_temp: 85
critical_gpu_temp: 90
# Fail-safe: consecutive failures before handing cooling back to BMC auto.
sensor_failure_limit: 3
write_failure_limit: 3
storage:
path: "/var/lib/only-fan-controller/history.db"
retention_days: 30 # History readings older than this are pruned daily
Example: Quiet Home Server
fan_control:
idle_speed: 15 # Very quiet
cpu_threshold: 70 # Higher threshold for quieter operation
gpu_threshold: 65
cooldown_delay: 120 # Wait 2 minutes before ramping down
Example: Workstation with GPUs
fan_control:
idle_speed: 25 # Slightly higher baseline
cpu_threshold: 60 # More aggressive cooling
gpu_threshold: 55
cooldown_delay: 30 # Faster response
Integration Example
Wrap your GPU-intensive scripts to automatically manage cooling:
#!/bin/bash
# transcribe-with-cooling.sh
CONTROLLER_URL="http://localhost:8086"
# Signal GPU work starting
curl -s -X POST "$CONTROLLER_URL/api/hint" \
-H "Content-Type: application/json" \
-d '{"type":"gpu_load","action":"start","intensity":"high","source":"whisper"}'
# Wait for fans to ramp up
sleep 3
# Do the actual work
whisper --model large-v3 "$1"
# Signal complete
curl -s -X POST "$CONTROLLER_URL/api/hint" \
-H "Content-Type: application/json" \
-d '{"type":"gpu_load","action":"stop","source":"whisper"}'
Environment Variables
All config options can be overridden via environment variables:
| Variable | Description | Default |
|---|---|---|
IDRAC_HOST |
iDRAC IP or "local" | from config |
IDRAC_USERNAME |
iDRAC username | root |
IDRAC_PASSWORD |
iDRAC password | - |
GPU_ENABLED |
Enable GPU monitoring | true |
FAN_IDLE_SPEED |
Base fan speed (%) | 20 |
FAN_CPU_THRESHOLD |
CPU temp threshold (°C) | 65 |
FAN_GPU_THRESHOLD |
GPU temp threshold (°C) | 60 |
CHECK_INTERVAL |
Seconds between checks | 10 |
API_PORT |
API/Dashboard port | 8086 |
API_TOKEN |
Bearer token for mutating endpoints | - (loopback-only) |
MQTT_ENABLED |
Enable Home Assistant MQTT bridge | false |
MQTT_BROKER |
MQTT broker URL (tcp://host:port) |
- (required when enabled) |
MQTT_USERNAME |
MQTT broker username | - |
MQTT_PASSWORD |
MQTT broker password | - |
Unraid Installation
Community Applications (recommended): open the Apps tab, search "Only Fan Controller", set your iDRAC host / username / password, and start it.
Manual: copy templates/only-fan-controller.xml to /boot/config/plugins/dockerMan/templates-user/ and configure via the Docker UI.
Upgrading
smart-fan-controller → only-fan-controller rename. The project (and the
Docker image) were renamed from smart-fan-controller to
only-fan-controller. If you have an existing deployment:
- The container name changed from
smart-fan-controllertoonly-fan-controller— update anydocker stop/docker logs/etc. scripts that reference the old name. - The GHCR image path changed accordingly (
ghcr.io/<owner>/only-fan-controllerinstead of.../smart-fan-controller); update yourdocker-compose.yml/docker runimage reference. scripts/hint-client.shnow readsFAN_URLinstead ofSMART_FAN_URLfor the controller base URL, but still honorsSMART_FAN_URLas a fallback if set — no forced script changes.
Config validation now refuses to start on unsafe configs. If your config
sets cpu_threshold (or gpu_threshold) at or above the corresponding
critical temperature's default — most notably cpu_threshold >= 85 without
also setting critical_cpu_temp explicitly above it — the service now exits
at startup with a clear error instead of silently falling back to default
thresholds. Set critical_cpu_temp/critical_gpu_temp explicitly, above your
configured thresholds, to fix this.
API token auth is a config-only addition. Existing deployments keep working unchanged; see API Security above.
History retention now defaults to 30 days. The readings table is
pruned automatically (see storage.retention_days in
config.example.yaml); previously it grew unbounded.
Requirements
- Dell PowerEdge server with iDRAC (tested on R730)
- iDRAC firmware with IPMI support
- For local mode:
/dev/ipmi0device access - For remote mode: IPMI over LAN enabled in iDRAC settings
- For GPU monitoring: NVIDIA drivers and
nvidia-smi
Building
# Build binary
go build -o only-fan-controller ./cmd/controller
# Build Docker image
docker build -t only-fan-controller .
License
MIT
Credits
Inspired by tigerblue77/Dell_iDRAC_fan_controller_Docker
Install Only-Fan-Controller on Unraid in a few clicks.
Find Only-Fan-Controller in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.
Categories
Related apps
Explore more like this
Explore allDetails
ghcr.io/sethpjohnson/only-fan-controller:latestRuntime arguments
- Web UI
http://[IP]:[PORT:8086]/dashboard/- Network
bridge- Shell
sh- Privileged
- false
Template configuration
Dashboard and API port
- Target
- 8086
- Default
- 8086
- Value
- 8086
iDRAC IP address (IPMI over LAN must be enabled)
- Target
- IDRAC_HOST
iDRAC username
- Target
- IDRAC_USERNAME
- Default
- root
- Value
- root
iDRAC password
- Target
- IDRAC_PASSWORD
Optional bearer token to change fans/hints via the HTTP API from other LAN hosts. Blank = local host only (does not affect Home Assistant/MQTT control).
- Target
- API_TOKEN
Enable the Home Assistant MQTT bridge (self-registers via MQTT Discovery)
- Target
- MQTT_ENABLED
- Default
- false
- Value
- false
MQTT broker. Just the IP works (e.g. 192.168.1.10); host:port or tcp://host:port also fine. Defaults to port 1883 (Mosquitto). This is the MQTT broker, NOT Home Assistant's web UI on 8123.
- Target
- MQTT_BROKER
MQTT broker username (optional)
- Target
- MQTT_USERNAME
MQTT broker password (optional)
- Target
- MQTT_PASSWORD
Monitor NVIDIA GPU temps. If you enable this, also add --runtime=nvidia to Extra Parameters (requires the Unraid NVIDIA Driver plugin). Leave off on CPU-only systems.
- Target
- GPU_ENABLED
- Default
- false
- Value
- false
Base fan speed when temps are below thresholds
- Target
- FAN_IDLE_SPEED
- Default
- 20
- Value
- 20
Bump fans above this CPU temp. Keep below the 85C critical limit.
- Target
- FAN_CPU_THRESHOLD
- Default
- 65
- Value
- 65
Bump fans above this GPU temp. Keep below the 90C critical limit.
- Target
- FAN_GPU_THRESHOLD
- Default
- 60
- Value
- 60
Fan increase per 5C over threshold
- Target
- FAN_STEP_SIZE
- Default
- 10
- Value
- 10
Seconds below threshold before ramping fans down
- Target
- FAN_COOLDOWN_DELAY
- Default
- 60
- Value
- 60
Seconds between temperature checks
- Target
- CHECK_INTERVAL
- Default
- 10
- Value
- 10
History database storage
- Target
- /var/lib/only-fan-controller
- Default
- /mnt/user/appdata/only-fan-controller/data
- Value
- /mnt/user/appdata/only-fan-controller/data