Replacing ASUSTOR ADM with Debian on an AS3102T: fan, LEDs and the parts nobody documents

I ran an ASUSTOR AS3102T on the stock ADM firmware for years. In October 2026 I replaced ADM 4.3.3.RY62 with plain Debian 13 (trixie). Everything I describe about ADM below was observed on that version, which is also the version I migrated from. Installing Debian on the box was simple. The hard part was the hardware ADM drives through closed-source userspace tools: the case fan and the front-panel LEDs. No public driver covers this model, so I had to work the details out on the running box. This article covers how I did that, what I found, and the scripts that now run in place of ADM.

If you own a different ASUSTOR model, check mafredri/asustor-platform-driver first. It supports many models. It does not support the AS31xx series (as of late 2025), and that is why I had to work it out myself.

Why leave ADM

  • Reboots hung regularly and the NAS had to be powered off by hand. Part of it turned out to be a stalling serial console that made every shutdown slow (more on that below), part is a BIOS freeze on warm reboot that I still can’t explain. Under ADM neither was easy to debug.
  • Outdated software in App Central. Apps update themselves daily, so what was installed is what the store offered: MariaDB 10.7.3 (a series out of support since 2023), git 2.35 from 2022, PHP 7.3 that reached end of life in 2021, Java 8u271 from 2020. DokuWiki lagged behind upstream too, and I ended up upgrading it by hand anyway.
  • Everything depends on ASUSTOR packaging it. Software that is not in the store, or a newer version of something that is, means building it yourself under a non-standard system. Firmware updates come on ASUSTOR’s schedule, not mine.
  • Little room for tuning. Hardware access goes through closed binaries (emboardmand, fanctrl, ledctrl, libnhal.so, the asleddrv LED kernel module). The root filesystem is a tmpfs, so changes outside the blessed config files do not survive a reboot. There are init scripts instead of systemd and apps install into /volume1/.@plugins. Every customisation was a fight. On Debian I can load kernel modules, set sysctls, write systemd units and read the source of what I run.
  • A far wider choice of software. The whole Debian archive and the official Docker repository, instead of a store with a few dozen apps.
  • Privacy and independence from the vendor. The NAS no longer needs an ASUSTOR account, cloud services or daily update checks against ASUSTOR servers. It only talks to the hosts I choose.
  • No vendor lock-in. The AS3102T is a 2015 machine. With Debian on it, it is just a small PC with two drive bays, and it keeps getting security updates for as long as Debian supports x86-64, whatever ASUSTOR decides about this model.

The hardware

Component Detail
CPU Intel Celeron N3050 (Braswell), 2 cores
RAM 2 GB
SATA Intel AHCI, 2 bays
NIC Broadcom BCM57781 (tg3)
Firmware Insyde BIOS V1.35 (11/2015), DMI vendor Insyde, product AS31xx
OS before migration ADM 4.3.3.RY62
Boot UEFI
Boot medium (ADM) internal USB DOM (disk on module, a small flash module plugged into a USB header on the mainboard), ADATA IUM01-512MFHL, 512 MB
Super I/O ITE IT8728F at 0x2e
Console serial ttyS0 115200 8N1 on the board, HDMI output works too

HDMI output works and so does a USB keyboard. Under ADM the HDMI screen only shows a cursor because ADM sends its console to ttyS0. On my first attempt the monitor showed “no signal”. Unplugging the HDMI cable and plugging it back in while the NAS was running fixed it.

The BIOS opens with Del. Things worth knowing:

  • Boot type is “Dual Boot”, USB boot enabled, EFI first, timeout 0. The Boot Manager lists the USB DOM and an internal EFI shell. You can start a Debian installer or live USB from the Boot Manager without changing any settings.
  • Serial redirection is on (COMA, 115200 8N1, VT100) and stays on after POST.
  • There is no fan control in the BIOS. It has only ACPI trip points, “FAN1 Device Enabled” and “Active Policy Disabled”. Whatever runs the fan has to be the OS.
  • During POST the BIOS sees only one of the two SATA ports. Linux sees both within 3 seconds. This is a late disk spin-up, not a fault.

Step 1: Look at what ADM does before you delete it

Running ADM is the best documentation you will get, so I studied it before wiping it. On ADM the hardware stack is:

  • emboardmand, a daemon that owns the fan and LEDs, configured from /usr/etc/emboard.conf and /etc/nas.conf
  • fanctrl and ledctrl, CLI tools that talk to libnhal.so, which has code paths for IT87, an MCU, LM63 and Intel CE PWM
  • /proc/asled, LED state exported by ADM’s kernel module

dmesg on ADM said gpio_it87: no device, and the it87 module found no hardware monitor. That suggested there was no IT87 chip at all, which turned out to be wrong.

strace on fanctrl

There is no strace on ADM, so I copied over a static build. Tracing fanctrl -getfanspeed showed what the library does:

  • ioperm() and then direct port I/O on 0x205/0x206, which is the index/data pair of an IT87 hardware monitor at ISA base 0x200
  • writes to port 0xeb, used only as an I/O delay
  • fanctrl -setfanpwm goes through the Super I/O config ports 0x2e/0x2f and then 0x207/0x208, and fails with -19 (ENODEV)

So it is an ITE chip after all, and under ADM its hardware monitor block sits at 0x200. Debian later showed that the BIOS default is 0x290, so ADM’s software apparently relocates it. Why ADM’s own it87 module never found the chip I did not establish. The Super I/O configuration interface at 0x2e/0x2f did not seem to answer at the time, which may already have been the stuck state described below.

With the ports known, a small Python script could dump all 256 registers through /dev/port. While emboardmand runs, it uses the same index/data ports. If two processes interleave their writes to the index port, both read the wrong register. To avoid that, the script takes ADM’s own POSIX semaphore around the reads. It only reads and never writes to a chip register:

python
# Read IT87 hardware monitor registers through index/data ports 0x205/0x206 (no chip register writes).
import ctypes, os
lib = ctypes.CDLL("libpthread.so.0")
lib.sem_open.restype = ctypes.c_void_p; lib.sem_open.argtypes = [ctypes.c_char_p, ctypes.c_int]
s = ctypes.c_void_p(lib.sem_open(b"/HW_SEMAPHORE_IT87XX", 0))
lib.sem_wait(s)
try:
    fd = os.open("/dev/port", os.O_RDWR)
    def rd(reg):
        os.pwrite(fd, bytes([reg]), 0x205)
        return os.pread(fd, 1, 0x206)[0]
    regs = [rd(r) for r in range(256)]
    os.close(fd)
finally:
    lib.sem_post(s)
for base in range(0, 256, 16):
    print("%02x: %s" % (base, " ".join("%02x" % v for v in regs[base:base+16])))

The dump gave the rest:

  • reg 0x58 = 0x90, the ITE vendor ID
  • PWM1 duty cycle = reg 0x63
  • fan1 tachometer = reg 0x0d (plus extension reg 0x18), RPM = 1350000 / (2 Ɨ count)

Things that will bite you on ADM

These cost me several reboots and one power cycle:

  • Manual fan mode crashes emboardmand on this model. It does not matter whether you switch it in /usr/etc/emboard.conf or in the ADM web UI: about two minutes after the switch the daemon dies with a general protection fault in libgeneraldrv.so, in its IT87 code. My guess is that this is the ENODEV path above. The dead daemon leaves the POSIX semaphore /dev/shm/sem.HW_SEMAPHORE_IT87XX locked, after which fanctrl and ledctrl hang and the LEDs go dark. Running fanctrl -setfanpwm by hand is no alternative either, the daemon overwrites the PWM right away.
  • Do not restart emboardmand while ADM is running (/etc/init.d/S93emboardmand restart). The stop/start unloads and reloads the asleddrv LED module and the LED state stays broken until the next reboot.
  • A warm reboot does not always fix it. After one of the crashes the daemon crashed again on the following warm boots, even in auto mode, and fanctrl -setfanpwm kept returning -19. The Super I/O runs on standby power, so a warm reboot presumably leaves it in whatever state the crash left it in. Unplugging the power for about 30 seconds fixed it.
  • In “Smart Fan” mode ADM kept the fan at PWM 31 (about 700 RPM, where the fan’s maximum is close to 4900 RPM) while the disks sat at 52 °C. That alone was a good reason to take over fan control.

Step 2: Test with a Debian live USB before installing

I booted debian-live-13 (kernel 6.12) from a Ventoy stick through the BIOS Boot Manager. This has two advantages: ADM’s own drivers are out of the way, and you can poke at hardware without risking the installed system.

One catch: the live system assembles your md arrays automatically (read-only). Run mdadm --stop on them before you experiment.

Identifying the Super I/O

The standard ITE sequence on port 0x2e enters config mode, and registers 0x20/0x21 hold the chip ID:

python
import os
fd = os.open("/dev/port", os.O_RDWR)
w = lambda port, val: os.pwrite(fd, bytes([val]), port)
r = lambda port: os.pread(fd, 1, port)[0]

for b in (0x87, 0x01, 0x55, 0x55):   # enter MB PnP mode (0x2e variant)
    w(0x2e, b)

def reg(n):
    w(0x2e, n); return r(0x2f)

print("chip id %02x%02x rev %d" % (reg(0x20), reg(0x21), reg(0x22) & 0x0f))
w(0x2e, 0x07); w(0x2f, 0x04)          # select LDN 4 = hardware monitor
print("HWM base %02x%02x" % (reg(0x60), reg(0x61)))
w(0x2e, 0x02); w(0x2f, 0x02)          # exit config mode
os.close(fd)

Result: IT8728F, chip ID 0x8728, revision 2, hardware monitor at 0x290. With the BIOS defaults left alone the mainline driver finds it on its own: it87: Found IT8728F chip at 0x290, revision 2.

The fan: mainline it87 works, with one twist

sh
modprobe it87 fix_pwm_polarity=1

Without the parameter the driver logs Detected broken BIOS defaults, disabling PWM interface and offers no pwm1 at all. The BIOS leaves fan control register 0x14 with the PWM outputs disabled and the polarity set to active low, which the driver treats as a misconfigured board. With the parameter it enables the outputs, but it also switches the polarity to active high (Reconfiguring PWM to active high polarity), and that part is wrong for this board: the PWM then works backwards. The fix is to clear bit 7 of register 0x14 again after the module loads (0xc7 → 0x47). The hardware monitor’s index/data ports are base + 5 and base + 6, so 0x295/0x296.

After that, pwm1 behaves:

pwm1 fan RPM
51 1175
120 2647
200 4041
255 4856

fan1_input reads correctly. temp1–temp3 report -128, meaning no sensors are wired. For temperatures I use the disks themselves through the drivetemp module.

Step 3: The LEDs

This took the most time. The front panel, top to bottom:

  1. LED1 power (green, orange)
  2. LED2 system/storage, the cylinder icon (green, red)
  3. LED3 network
  4. LED4 disk

Where the LED lines are

There are two possible places: the Braswell chipset GPIOs and the IT8728F’s own GPIO pins.

Chipset GPIO. Braswell has four GPIO controllers (ACPI INT33FF:00–03). On this board the ACPI _STA method returns 0 for all of them, so Linux never binds pinctrl-cherryview to them and you get no /sys/class/gpio lines. The registers are still there in MMIO, though, and /dev/mem can map them even with STRICT_DEVMEM, because no driver claims the region. The pad configuration registers of the North community that matter here start at physical address 0xfed8c800, 8 bytes per pad. Bit 1 of a pad’s first register (PADCFG0) is the output (TX) state. ADM’s configuration gave me a starting point: it refers to the pad at 0xfed8c810 as the HDD LED. Toggling that pad changed nothing visible, so in the end I went through every pad configured as an output.

IT8728F simple I/O. Logical device 7 holds the GPIO configuration. Its registers 0x62/0x63 give the simple I/O base, here 0x600. GPIO sets 1–5 (GP1x–GP5x) sit at 0x600–0x604, one bit per pin.

There is no datasheet for the board, so the method was brute force. For each output pin I flipped the bit, waited a few seconds, flipped it back, and watched the front panel. A pin that does nothing visible is not necessarily unused. It may drive something you can’t see, so put every pin back to its original value.

Result:

LED Controlled by Active
LED2 red chipset pad 0xfed8c830, bit 1 1 = on
LED1 orange IT8728F GP20 (port 0x601, bit 0) 0 = on
LED1 green + LED3 together IT8728F GP45 (port 0x603, bit 5) acts like a common enable

LED2 green, LED3 on its own and LED4 did not respond to any GPIO I could find. They are most likely wired straight to hardware signals, like NIC link/activity and SATA activity. None of the other chipset pads (26 tested) or IT87 pins changed anything visible.

One more ADM detail: the slow orange blink on LED1 (1 s on, 7 s off) under ADM is /proc/asled LED 0 in mode 3, status 2. emboardmand sets it once boot finishes, and the blinking runs in a kernel timer. I never found out what it means. In the BIOS it does not blink, and under Debian nothing drives that LED except my own script.

Step 4: Installing Debian

Where to put the system

The AS3102T offers three places for the root filesystem. The data array on md1 stays untouched in all three, Debian just assembles it as an existing array.

Option Capacity Pros Cons
1. USB SSD or a good flash drive in a rear USB 3.0 port any ADM stays intact, going back is just a matter of which device boots, quick to experiment with A single device with no redundancy. A plain flash drive wears out quickly under constant writes, so use a small USB SSD and limit writes (noatime, logs in RAM).
2. The internal 512 MB flash (in place of ADM) 512 MB Inside the case, nothing sticks out Too small for a whole system: only /boot and the ESP fit, the root has to live elsewhere. ADM is erased, though the whole DOM can be imaged first (dd, 512 MB) and restored later.
3. Partitions on the data disks, RAID1 ~4 GB (the space ADM used for volume0 and swap) Survives the loss of one disk, the most robust option Reuses ADM’s system partitions, so ADM stops working and the step is one-way. 4 GB for root is tight but enough for a server once Docker, data and logs live on md1. Needs an ESP on sda1/sdb1. Shrinking md1 to make room is possible but means hours of offline work on 7 TB of data, not worth the risk.

I went with option 1 first and option 3 for good. I first ran Debian from a USB stick with ADM still on the DOM as a fallback. Once everything worked I moved it to the disks:

  • Partition 1 on each disk: EFI system partition (255 MB), labelled EFI-A and EFI-B
  • Partition 2 on each disk: RAID1 for the root filesystem (4 GB, ext4)
  • Partition 4: the existing ADM data array (md1), left untouched and mounted at /volume1 so that paths in Docker Compose files and configs stay valid
  • GRUB registered in NVRAM for both disks, plus a copy at the removable path (EFI/BOOT/BOOTX64.EFI) on both ESPs, so either disk boots alone

ADM’s data array is a standard Linux md RAID1 with ext4 on top. Debian assembles it without anything special.

Keeping the 4 GB root small

Option 3 only works if the root filesystem stays small, which means deciding up front what lives on /volume1 instead. The system with the services below uses 2.6 GB (71 %), so there is little slack. What I moved or capped:

  • Docker data-root in /volume1/docker (the images and containers alone are many gigabytes)
  • Home directories: /home is a bind mount of /volume1/home, which also keeps ADM’s paths valid
  • Web roots and application data (DokuWiki, Nextcloud) on /volume1, where ADM already had them
  • Backups and migration leftovers on /volume1. I learned this the hard way: unpacking the saved ADM volume0 image into /root filled the 4 GB root in one go.
  • Netdata’s database in /volume1/.nasoska/netdata-cache, with RequiresMountsFor=/volume1 in the service so it does not start before the array is mounted
  • journald capped at SystemMaxUse=100M
  • apt set not to keep downloaded packages (Keep-Downloaded-Packages "false"), and Acquire::Languages "none" plus Acquire::GzipIndexes "true" to shrink the package lists from 179 MB to 107 MB

The things that grow over time (logs, journal, monitoring data) turned out to be small once capped. Most of the root is packages: of the 2.6 GB in use, 2.2 GB is /usr, and Netdata alone accounts for about 410 MB of that. /var takes 338 MB and /boot 108 MB.

The serial console hang

This explained the slow shutdowns I had under ADM. The serial console on ttyS0 stalls: under Debian, systemd waited 30 seconds on every status line it printed during boot and shutdown. ADM sends its console to the same port, so its shutdowns suffered the same delays.

Fix: remove console=ttyS0,115200n8 from the kernel command line and mask serial-getty@ttyS0.service. If you need a console, use HDMI.

BIOS freeze on warm reboot

Occasionally the BIOS freezes right at the start after a warm reboot. It happened once under Debian live and never in six targeted attempts. A cold start never froze. When it did freeze, pressing Esc and choosing the boot entry in the Boot Manager got it going. My unconfirmed guess is a Super I/O left in a bad state, like the emboardmand case above.

Workaround: Wake-on-LAN works (tg3, ethtool shows Wake-on: g). A “reboot” can then be poweroff followed by a magic packet from another machine, which avoids the warm-reboot path completely.

Migrating accounts and services

A few notes in case you migrate a live ADM install rather than start fresh:

  • Recreate users with the same UID/GID as on ADM so file ownership on /volume1 stays correct. Password hashes can be copied from ADM’s /etc/shadow.
  • Samba: importing ADM’s tdbsam database failed for me. Setting each user’s NT hash with pdbedit --set-nt-hash worked.
  • ADM’s sshd effectively ran with AllowGroups root administrators. The AllowUsers line in its config was ignored. Check what your other machines depend on before you tighten or loosen SSH.
  • ADM’s Docker data lives in /volume1/.@plugins/AppCentral/docker-ce/docker_lib. I moved it to /volume1/docker and pointed data-root there.

Step 5: Replacing emboardmand

Two small Python services do what emboardmand used to do.

Fan

/etc/modules-load.d/nas.conf:

code
it87
drivetemp

/etc/modprobe.d/it87.conf:

code
options it87 fix_pwm_polarity=1

/usr/local/sbin/nas-fan.py maps the hottest disk’s temperature linearly from 35–50 °C onto PWM 60–255 and runs the fan at full speed if no temperature can be read:

python
#!/usr/bin/python3
# Fan control for ASUSTOR AS3102T (IT8728F) driven by the hottest disk (drivetemp).
# it87 loads with fix_pwm_polarity=1, but this board needs the opposite polarity,
# so bit 7 of register 0x14 is cleared again on start.
import glob, os, time

T_MIN, T_MAX = 35.0, 50.0      # below T_MIN minimum, from T_MAX full speed
PWM_MIN, PWM_MAX = 60, 255
FAILSAFE = 255                 # full speed if temperatures can't be read

def hwmon(name):
    for d in glob.glob("/sys/class/hwmon/hwmon*"):
        if open(d + "/name").read().strip() == name:
            yield d

def fix_polarity():
    fd = os.open("/dev/port", os.O_RDWR)
    try:
        os.pwrite(fd, b"\x14", 0x295)
        v = os.pread(fd, 1, 0x296)[0]
        if v & 0x80:
            os.pwrite(fd, b"\x14", 0x295)
            os.pwrite(fd, bytes([v & 0x7F]), 0x296)
    finally:
        os.close(fd)

def disk_temp():
    temps = [int(open(d + "/temp1_input").read()) / 1000 for d in hwmon("drivetemp")]
    return max(temps) if temps else None

def pwm_for(t):
    if t is None:
        return FAILSAFE
    k = min(max((t - T_MIN) / (T_MAX - T_MIN), 0.0), 1.0)
    return int(PWM_MIN + k * (PWM_MAX - PWM_MIN))

it87 = next(hwmon("it8728"))
fix_polarity()
open(it87 + "/pwm1_enable", "w").write("1")
last = None
while True:
    pwm = pwm_for(disk_temp())
    if pwm != last:
        open(it87 + "/pwm1", "w").write(str(pwm))
        last = pwm
    time.sleep(10)

LEDs

/usr/local/sbin/nas-led.py turns on the red LED2 when an md array is degraded or resyncing, or when SMART reports a failing disk. It turns on the orange LED1 when a disk reaches 50 °C. The green LEDs stay as the BIOS left them. --test lights each LED for 5 seconds in turn, which is handy after a kernel or BIOS change.

python
#!/usr/bin/python3
# Status LEDs for ASUSTOR AS3102T on Debian.
#   LED2 red (Braswell pad 0xfed8c830 bit 1, 1 = on): degraded md array or SMART failure
#   LED1 orange (IT8728F GP20, simple I/O port 0x601 bit 0, 0 = on): hottest disk >= 50 C
import glob, mmap, os, re, signal, struct, subprocess, sys, time

TEMP_LIMIT = 50.0
SMART_EVERY = 1800
RED_ADDR, RED_PAGE = 0xfed8c830, 0xfed8c000
ORANGE_PORT = 0x601

def set_orange(on):
    fd = os.open("/dev/port", os.O_RDWR)
    try:
        v = os.pread(fd, 1, ORANGE_PORT)[0]
        nv = (v & ~1) if on else (v | 1)
        if nv != v:
            os.pwrite(fd, bytes([nv]), ORANGE_PORT)
    finally:
        os.close(fd)

def set_red(on):
    fd = os.open("/dev/mem", os.O_RDWR | os.O_SYNC)
    try:
        mm = mmap.mmap(fd, 0x1000, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE, offset=RED_PAGE)
        off = RED_ADDR - RED_PAGE
        v = struct.unpack("<I", mm[off:off + 4])[0]
        nv = (v | 0x2) if on else (v & ~0x2)
        if nv != v:
            mm[off:off + 4] = struct.pack("<I", nv)
        mm.close()
    finally:
        os.close(fd)

def md_problem():
    t = open("/proc/mdstat").read()
    # [UU] = healthy, underscore = missing member; resync/recovery = array rebuilding
    return bool(re.search(r"\[[U_]*_[U_]*\]", t)) or bool(re.search(r"recovery|resync|reshape", t))

def smart_failed():
    bad = False
    for dev in ("/dev/sda", "/dev/sdb"):
        r = subprocess.run(["smartctl", "-H", dev], capture_output=True, text=True)
        # Exit status bit 3 = disk failing, bit 4 = prefail attribute below threshold now.
        # Bit 5 (threshold crossed only in the past) is ignored. "FAILED" also appears in the
        # WHEN_FAILED column header, so only the overall-health line is checked.
        health = [l for l in r.stdout.splitlines() if "overall-health" in l]
        if r.returncode & 0b11000 or (health and health[0].rstrip().endswith("FAILED")):
            bad = True
    return bad

def max_disk_temp():
    temps = []
    for d in glob.glob("/sys/class/hwmon/hwmon*"):
        if open(d + "/name").read().strip() == "drivetemp":
            temps.append(int(open(d + "/temp1_input").read()) / 1000)
    return max(temps) if temps else None

def off_and_exit(*_):
    set_red(False)
    set_orange(False)
    sys.exit(0)

if len(sys.argv) > 1 and sys.argv[1] == "--test":
    for name, fn in (("orange", set_orange), ("red", set_red)):
        print(name, "on for 5 s", flush=True)
        fn(True); time.sleep(5); fn(False)
    sys.exit(0)

signal.signal(signal.SIGTERM, off_and_exit)
signal.signal(signal.SIGINT, off_and_exit)
smart_bad, last_smart = False, 0.0
state = None
while True:
    if time.time() - last_smart >= SMART_EVERY:
        smart_bad, last_smart = smart_failed(), time.time()
    t = max_disk_temp()
    red = md_problem() or smart_bad
    orange = t is not None and t >= TEMP_LIMIT
    set_red(red)
    set_orange(orange)
    if (red, orange) != state:
        print("LED: red=%s orange=%s (temp %s C, SMART error %s)" % (red, orange, t, smart_bad), flush=True)
        state = (red, orange)
    time.sleep(60)

The SMART check needed one correction. My first version lit the red LED on a healthy disk. It matched the word FAILED in the attribute table header (WHEN_FAILED), and smartctl also set exit status bit 5 because the disk temperature had crossed its threshold at some point in the past, under ADM.

Both scripts run as root, because they need /dev/port and /dev/mem, from minimal systemd units. nas-fan.service:

ini
[Unit]
Description=Fan control by disk temperature
After=systemd-modules-load.service

[Service]
ExecStart=/usr/local/sbin/nas-fan.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

nas-led.service is the same with nas-led.py and RestartSec=30.

Step 6: Tuning for two Celeron cores

With 2 cores and 2 GB of RAM, what matters most is the overhead of things running idle. CPU figures are percentages of one core, measured over 2 minutes outside backup windows.

What Before After Change
Netdata with plugins ~20 % CPU, plugins ~150 MB RAM ~11 % CPU, ~30 MB RAM update every = 2, plugins ebpf, ebpf-go, otel, scripts.d, python.d, charts.d, nfacct, tc, network-viewer and debugfs disabled
dockerd + containerd-shim ~12 % CPU ~4 % CPU Nextcloud container healthchecks every 60 s instead of 10 and 30 s, with start_interval: 5s so startup is still detected quickly
Total idle overhead ~32 % of a core ~15 % of a core

Smaller things that helped:

  • TLS session cache in nginx for DokuWiki (ssl_session_cache shared:SSL:2m). A repeat connection skips the full handshake, which is expensive on this CPU. HTTP/2 is on for both DokuWiki and Nextcloud.
  • Nextcloud’s missing database indices added (occ db:add-missing-indices).
  • rpcbind removed (NFS is not used).
  • Sysctl settings taken over from ADM’s own init scripts: net.ipv4.tcp_timestamps = 0, vm.swappiness = 10, net.core.rmem_max and wmem_max 4 MB.

Response times afterwards: DokuWiki 50–120 ms per page, Nextcloud status.php 110–220 ms, login page about 0.5 s.

Tried and rejected:

  • Read-ahead on md1 (128 kB versus 2, 4 and 8 MB): sequential reads stayed at 109–145 MB/s whatever the setting, so the default 128 kB stays. ADM’s 2048 kB read-ahead on sda/sdb is kept as a udev rule but has no effect on reads through md1. A direct read from one disk gives about 196 MB/s.
  • PHP-FPM ondemand for DokuWiki: the first page after 30 s of idle took 1.7 s. It stays dynamic.
  • HTTP/3: nginx 1.26 supports it, but in a LAN it gains almost nothing and QUIC costs more CPU than HTTP/2.
  • I/O scheduler: mq-deadline is the right choice for spinning disks, BFQ would cost CPU. One consequence: ionice -c2 -n7 on the nightly backup does nothing under mq-deadline. If the backups ever get in the way, the idle class (-c3) would.

Left alone because it was already right: ext4 with noatime, RAID1 with an internal write-intent bitmap (64 MB chunks), disk write cache on, no disk spin-down (the box runs around the clock for Nextcloud and Syncthing), Nextcloud with APCu, Redis and OPcache.

Monitoring is the usual set: mdadm --monitor and smartd (short self-test daily, long one weekly, temperature warnings at 45/55 °C) writing to syslog, root mail through a Postfix satellite, NRPE checks for Nagios, a Wazuh agent and unattended-upgrades for security updates. All of it, including the fan and LED services, lives in an Ansible role, so a manual change on the NAS has to go into the role or the next run overwrites it.

What is still open

  • LED2 green, LED3 and LED4 can’t be controlled from software as far as I can tell.
  • The meaning of ADM’s orange blink is unknown.
  • The occasional BIOS freeze after a warm reboot is not explained. Cold boot plus Wake-on-LAN avoids it.
  • The register addresses here are specific to the AS3102T with BIOS V1.35. On other AS31xx/AS32xx boards, verify them with the blink test before you write to /dev/mem or /dev/port. A wrong write to a chipset pad can do more than light an LED.

Summary of the useful numbers

What Where
Super I/O IT8728F, config ports 0x2e/0x2f
Hardware monitor 0x290 (ADM moves it to 0x200)
PWM polarity fix HWM reg 0x14, clear bit 7 (ports 0x295/0x296)
Fan driver it87 fix_pwm_polarity=1, pwm1, fan1_input
IT8728F simple I/O base 0x600 (LDN 7, regs 0x62/0x63)
LED1 orange 0x601 bit 0, active low
LED1 green + LED3 0x603 bit 5 (GP45)
LED2 red MMIO 0xfed8c830 bit 1, active high
Braswell GPIO INT33FF:00–03 disabled in ACPI, use /dev/mem
Serial console ttyS0 115200 8N1, stalls, keep it off the kernel cmdline