A small Go daemon that keeps specific USB-attached drives spinning during configured time windows, and leaves them alone the rest of the time so their own default idle timer can spin them down naturally.
It includes a thin read-only web UI for viewing current configuration, per-disk runtime status, and live in-process logs.
USB-SATA bridge chips often
don't reliably honor a long ATA standby timer set via hdparm -S or
smartctl -s standby,N — the bridge's own internal housekeeping resets the
timer before it ever fires. What does reliably work is the bridge's own
factory-default idle timer, which is based on genuine USB bus activity, not
an ATA register.
Rather than fighting the bridge, diskwake works with it: during your
configured "awake" windows, it performs a tiny real read directly against
the raw block device every keepalive_interval, which resets the bridge's
own idle timer via genuine bus traffic. Outside those windows, it does
nothing at all, and the drive falls asleep on its own after its normal
default idle period.
diskwake opens the raw device (e.g. /dev/disk/by-id/usb-...) with
O_DIRECT and reads the first 4096 bytes with pread. O_DIRECT bypasses
the kernel page cache, guaranteeing the read actually reaches the physical
drive over USB rather than being served from cache and silently doing
nothing.
disks:
- name: backup-drive
device: /dev/disk/backup-drive
keepalive_interval: 5m
windows:
- start: "08:00"
end: "23:00"
days: ["monday", "tuesday", "wednesday", "thursday", "friday"]
- name: media-drive
device: /dev/disk/media-drive
keepalive_interval: 5m
windows:
- start: "18:00"
end: "22:00"
- start: "07:00"
end: "09:00"
days: ["saturday", "sunday"]device— the path inside the container, not the host. This is a name you choose yourself (see "Host vs. container paths" below) — it doesn't need to look like a real device path at all, as long as it matches what you map in your compose file.keepalive_interval— any Go duration string (5m,90s,1h, ...). Keep this comfortably shorter than the drive's own default idle timeout (commonly ~10-30 minutes for USB enclosures) or the drive will still spin down inside an "awake" window.windows— one or morestart/endpairs in 24hHH:MM. A window may cross midnight (e.g.start: "22:00",end: "02:00"). Multiple windows per disk are supported. Outside all windows, the disk is left alone entirely.days(optional) — limits a window to one or more weekdays (["monday", "wed"], case-insensitive).day(optional, legacy) — single-day form kept for backward compatibility.dayanddaysare mutually exclusive for a single window; setting both is a startup validation error. For a midnight-crossing window (for example22:00 -> 02:00), configured weekdays run from the named day(s) into the following early morning.
Times are evaluated in the container's local timezone — set TZ in the
compose environment to match your server.
This is the one thing worth understanding before setting this up.
config.yamlonly ever sees a path inside the container (/dev/disk/backup-drivein the example above). This name is arbitrary — pick anything descriptive. Because it's arbitrary,config.yamlnever needs to change even if your host reassigns/dev/sdXletters after a reboot.- Your compose file is the only place that needs your actual,
host-specific device path, via the
devices:mapping (host_path:container_path). Always use the host's stable/dev/disk/by-id/...path here, never/dev/sdXdirectly:ls -l /dev/disk/by-id/
This split means config.yaml and the rest of the project are fully
generic and safe to commit/share as-is — only your local compose file
needs host-specific edits.
Create a compose.yaml next to config.yaml using this minimal service:
services:
diskwake:
image: ghcr.io/ivanbeke/diskwake:latest
container_name: diskwake
restart: unless-stopped
environment:
TZ: Etc/UTC
volumes:
- ./config.yaml:/etc/diskwake/config.yaml:ro
ports:
- "8080:8080"
devices:
- "/dev/disk/by-id/REPLACE_WITH_YOUR_DISK_1_ID:/dev/disk/backup-drive"
- "/dev/disk/by-id/REPLACE_WITH_YOUR_DISK_2_ID:/dev/disk/media-drive"Then:
- Edit
config.yamlwith your desired windows. - Replace
REPLACE_WITH_YOUR_DISK_*_IDwith real host IDs fromls -l /dev/disk/by-id/. - Start it:
docker compose up -d
docker logs -f diskwakecompose.example.yaml is kept in this repo as a development-focused local
build template.
No privileged: true or extra capabilities are needed — a plain
O_DIRECT read only requires normal read access to the device node, which
Docker's devices: mapping already grants.
The read-only web UI runs by default on port 8080. Publish the container port in compose:
ports:
- "8080:8080"To change the UI port, use either CLI flag or env var:
command: ["--config", "/etc/diskwake/config.yaml", "--port", "8090"]or
environment:
TZ: Etc/UTC
DISKWAKE_PORT: "8090"Then open http://<your-server-ip>:8080 on your LAN.
- Enabled by default on
:8080. - Override with
--portorDISKWAKE_PORT. - LAN-accessible when the container/host port is published.
- No authentication is built in.
- Shows current runtime state only (not persisted historical logs).
UI endpoints:
/— dashboard/api/status— JSON server time/timezone and per-disk status/api/config— JSON read-only config path/content/api/logs— JSON current in-memory log buffer/api/logs/stream— live log stream (Server-Sent Events)
You should see startup log lines listing the loaded disks/windows, followed
by a line every keepalive_interval — either keep-awake read OK while
inside a window, or outside configured windows, leaving idle otherwise.
Since the container logs every tick, you can quickly sanity-check a config
change by setting a window that includes right now and a short
keepalive_interval (e.g. 30s), watching the logs, then restarting with
your real values once confirmed:
docker compose restart diskwake
docker logs -f diskwake- This intentionally does not touch
smartd,hdparm, or any ATA standby timer — it relies entirely on the bridge's own default idle behavior, triggered/reset by genuine bus activity. - If a disk still won't stay awake during its window, try lowering
keepalive_interval— the enclosure's true default idle timeout may be shorter than assumed.
diskwake/
├── .github/
│ └── workflows/
│ └── docker-publish.yml
├── Dockerfile
├── README.md
├── compose.example.yaml
├── config.go
├── config.yaml
├── config_test.go
├── go.mod
├── go.sum
├── main.go
├── main_test.go
├── reader.go
├── state.go
└── web.go
Run tests:
go test ./...Run directly on host (for development):
go run . --config ./config.yaml --port 8080Build a local binary:
go build -o diskwake .Build the container image locally:
docker build -t diskwake:dev .Container publish automation lives in .github/workflows/docker-publish.yml.
The workflow runs tests and then builds/pushes/signs the GHCR image on pushes
to main and version tags (v*).