-
Notifications
You must be signed in to change notification settings - Fork 7
Phase 1 De Ubiquitizing
The UCK-G2-Plus is a genuinely nice piece of hardware for $200: powered over Ethernet (PoE, so one cable does both data and power), 64-bit ARM (ARM64), a 2.5" drive bay, a battery for clean shutdown on power loss, and a 160×60 color LCD on the front. Under the hood it's just Debian 11. This is a complete guide to stripping the UniFi software off one and turning it into a generic Debian server — including the exact mistake that bricked the first attempt, so you don't repeat it.
- SoC (system-on-chip): Qualcomm APQ8053, aarch64 — the kernel's name for
the same 64-bit ARM architecture called "ARM64" above. The board's
base-files package gives the exact model away —
cloudkey-plus-apq8053-base-fileson the Gen2 Plus,cloudkey-g2-apq8053-base-fileson the plain Gen2. The SoC, kernel, package set, and every command below are identical across the two; only that package name and the Plus's internal drive bay (Step 7) differ, so this guide applies to both — substitute your own base-files name wherever the-plus-one appears. - OS: Debian 11 (bullseye), kernel
3.18.44-ui-qcom. - This is the newer "native" UniFi OS architecture — no Docker
involved. Every UniFi component is an ordinary
.debpackage installed on top of stock Debian, not baked into a locked firmware image, andaptsources are plain Debian (deb.debian.org) with no Ubiquiti repo configured. This is what makes de-Ubiquitizing tractable at all. - Storage: internal eMMC (
mmcblk0, ~29G) holds the boot/rootfs in a Qualcomm A/B-style partition layout — never touch this, it's the bootloader and recovery path. A separate internal SATA/SSD (sda, up to the drive's full size) is what UniFi OS partitions for its own swap, system volumes, and a large/volumepartition for bulk storage (Protect footage, backups, etc. — check what's actually on yours before reusing it). - Front panel: a 160×60, 16bpp framebuffer at
/dev/fb0(driverfb_sp8110), plus two controllable LEDs at/sys/class/leds/blueand/sys/class/leds/white. - There's a genuine serial console header on the board: J22, 3.3V TTL — but you cannot access it without opening the device.
- Battery: internal, used for exactly one thing — giving the box enough runtime to shut down cleanly if PoE/USB power is cut. See the deep dive below; it's a much simpler mechanism than it sounds.
-
Have a recovery path that doesn't depend on the network. Accept that your fallback is a full factory restore via Ubiquiti's firmware/recovery mode, which wipes the device back to stock. SSH-only access with no console fallback is how a small mistake turns into "start over from scratch."
-
Enable SSH via System Settings on the device first, and confirm you can log in as
rootwith your key before doing anything else. -
Back up the package/unit state, even though it's not a true restore mechanism (a factory reset wipes it too — it's just useful context if something goes wrong before that point):
ssh root@<device-ip> "dpkg --get-selections > /root/pre-cleanup-dpkg-selections.txt" ssh root@<device-ip> "systemctl list-unit-files --state=enabled > /root/pre-cleanup-enabled-units.txt"
-
Check what's actually on your
/volumepartition before assuming it's safe to reclaim — if this device was ever running UniFi Protect, there may be camera footage or backups on it.
This is the section that would have saved a full factory reset. Read it before running anything.
apt-get purge silently removes anything that depends on what you list,
with no prompt — unlike plain apt remove/apt purge run interactively,
which shows "the following additional packages will be REMOVED" before
acting. If you don't simulate first, you won't see this happening until
it's too late.
Two packages must never be purged, because of real dependency chains
(not guesses — confirmed via apt-cache depends):
base-files-deps-common Depends: ubnt-tools
cloudkey-plus-apq8053-base-files Depends: base-files-deps-common
ck-splash Depends: ck-ui | <udr-ui> (nothing else provides udr-ui)
cloudkey-apq8053-initramfs Depends: ck-splash
Warning
Purging ubnt-tools cascades into removing
cloudkey-plus-apq8053-base-files (or cloudkey-g2-apq8053-base-files on
the plain Gen2 — same chain, model-specific name). Purging ck-ui
cascades into removing cloudkey-apq8053-initramfs (not model-specific —
the same package on both). Losing either is very likely to brick the
device because SSH is either turned off or unavailable (hard to tell).
The first of those base-files packages is this board's actual
base-files-equivalent package (apt/dpkg hooks, sysctl tuning, SSH PAM
usermap config, and — critically — the entire power-loss protection
chain, see below). The second, the initramfs package, manages the
initramfs the board boots from. This is exactly what happened on the
first attempt: the transaction silently swept up both packages alongside
the intended UniFi removal, the box came back from a manual reboot not
even responding to ping — a boot failure, not just a stopped service —
and a full firmware-mode factory restore was required.
Neither ck-ui nor ubnt-tools need to actually be removed to get a clean
result. ck-ui is Ubiquiti's front-panel LCD app — replace it with your
own program at the service level (stop/disable ck-ui.service, run your
own systemd unit instead) and leave the package installed but dormant.
ubnt-tools mainly provides infctld, which — despite the tempting
"emergency" systemd unit name — is just Ubiquiti's network discovery
daemon (UDP 10001 discovery + CDP advertisements), unrelated to anything
critical. Disable its service if you don't want it; don't purge the
package.
This is worth understanding on its own, because it's simpler — and more generically useful — than "a closed-source binary listening for interrupts." The whole chain:
-
The kernel exposes standard Linux
power_supplyclass devices at/sys/class/power_supply/(battery,ext_battery,mains,usb,typec— via the Qualcommqpnp-smbchargerPMIC driver). When PoE/USB power is lost, the relevant supply'sonlineattribute flips to0. -
A udev rule,
/lib/udev/rules.d/40-powerloss.rules, owned bycloudkey-plus-apq8053-base-files:# Ubiquiti APQ8053 Cloudkey powerloss ACTION=="change", SUBSYSTEM=="power_supply", ATTR{online}=="0", \ RUN+="/bin/kill -SIGPWR 1"catches that change and sends
SIGPWRstraight to PID 1. -
sigpwr.targetis a stock systemd special target (part of systemd itself,man systemd.special), which systemd reaches on receiving that signal — nothing Ubiquiti-specific here at all. -
device-powerloss.service(WantedBy=sigpwr.target, also owned bycloudkey-plus-apq8053-base-files) runs on reaching that target. -
That "binary" is actually a one-line POSIX shell script:
systemctl --no-block poweroff
That's it. The battery's whole job is to buy enough time for a completely
ordinary systemctl poweroff to run — systemd's normal shutdown ordering
stops every running service (sending SIGTERM, letting each one flush and
exit) before finally unmounting filesystems and halting. There's no bespoke
data-safety logic to worry about breaking. And because it's just "cleanly
shut down whatever's currently running," this protection keeps working
for free for whatever you run on the box afterward — it was never
specific to UniFi's own software in the first place.
Warning
Never purge the board's base-files package
(cloudkey-plus-apq8053-base-files, or cloudkey-g2-apq8053-base-files on
the plain Gen2), and never delete or disable 40-powerloss.rules or
device-powerloss.service. There is no legitimate reason removing the
UniFi application layer would ever need to touch this — it isn't a UniFi
package, and losing it means no clean shutdown the next time PoE or USB
power drops.
This is the single highest-leverage habit in this whole process:
apt-get purge --simulate <package list>Read the entire "will be REMOVED" list, not just the packages you typed.
If anything unexpected shows up — especially anything with "initramfs",
"base-files", "bootloader", "kernel", or a board/model name in it — stop
and run apt-cache depends <that package> to find out why it's being
pulled in, rather than guessing or forcing past it. That single check
would have prevented the entire incident described above.
for u in ck-splash-reboot.service ck-splash-shutdown.service \
ck-ui.service infctld-emergency.service infctld.service \
ubnt-systemhub.service ubnt-unifi-setup.service \
ucore-setup-listener.service ucs-agent.service uhwd.service \
uid-agent.service ulp-go.service unifi-core.service \
unifi-directory.service unifi-identity-update.service \
"unifi-sdcard@.service" unifi.service uos-agent.service \
uos-discovery-client.service usd.service usdbd.service \
ubnt-dpkg-restore.service; do
systemctl disable --now "$u"
doneA couple of things to expect here, both harmless:
-
unifi-sdcard@.serviceis a systemd template unit; you'll get a "missing the instance name" error trying to stop the bare template. The actual enablement symlink still gets removed correctly. -
ubnt-dpkg-restore.serviceis worth specifically calling out: it's gated byConditionPathExists=/boot/.fwupdate, so it's normally a no-op (it only fires right after an OTA firmware update). Still disable it — its only job is reinstalling the exact packages you're about to remove.
Verify:
systemctl list-unit-files --state=enabled | \
grep -iE 'unifi|ubnt|uos-|ucs-|uid-agent|ulp-go|^ck|infctld|^usd'Should return nothing except ubnt-zram-swap.service (a generic zram swap
setup script worth keeping regardless of UniFi).
The corrected package list (ck-ui and ubnt-tools deliberately excluded,
per the danger-zone section above):
unifi-assets-uckp unifi-assets-uckg2
unifi-email-templates-all python3-unifi-console-protos
mongodb-server mongodb-clients mongodb-server-core
unifi unifi-core
unifi-directory unifi-identity-update uid-agent ucs-agent uos-agent
uos-discovery-client uos ulp-go
ustd ubnt-systemhub ubnt-unifi-setup ucore-setup-listener
Package names vary a little by Cloud Key generation (unifi-assets-uckp
versus unifi-assets-uckg2, for example). Simulate the list that is
actually installed or left in config-only state on your box, confirm it
removes only approved targets and nothing else, then run each batch for
real:
apt-get purge -y <batch>
ssh root@<device-ip> uptime # confirm still alive before the next batchDon't run this as one 20-package command. If a batch fails or the box
stops responding, you want to know which 3-8 packages were involved, not
which 20. In practice apt will sometimes resolve dependency ordering
differently than your manual batching expects (e.g. unifi-core getting
pulled into an earlier batch because something in that batch depended on
it) — that's fine as long as everything pulled forward was already on your
approved list.
Finish with:
apt-get --purge autoremove -yVerify:
dpkg -l | grep -iE 'unifi|ubnt|uos-|ucs-|uid-agent|ulp-go'Should show only the deliberately-kept packages: ck-ui, ubnt-tools,
ubnt-zram-swap, ubnt-disk-smart-mon, ubnt-archive-keyring, and any
~ubnt-suffixed builds of stock tools like jq (functionally identical to
Debian's own build, not worth replacing).
If you want to keep the LCD useful, ck-ui needs a replacement — several
exist on GitHub (e.g. jnovack/cloudkey).
That project publishes a pre-built cloudkey-linux-arm binary with every
release, so no cross-compile toolchain is needed on the device or off it —
download it directly on the box, rename it, and install:
curl -fsSL -o /root/cloudkey \
https://github.com/jnovack/cloudkey/releases/latest/download/cloudkey-linux-arm
chmod 755 /root/cloudkeyNote
Only one architecture is published (cloudkey-linux-arm, 32-bit). It
runs unmodified on the Gen2 Plus covered here because this board's
Qualcomm SoC supports the AArch32 execution state alongside its native
64-bit one — there is no separate arm64 build to look for.
The systemd unit and an env template live in the repo itself rather than as release assets — pull the copies matching the release you just downloaded, using the tag from the GitHub API:
TAG=$(curl -fsSL https://api.github.com/repos/jnovack/cloudkey/releases/latest \
| grep -m1 '"tag_name"' | cut -d'"' -f4)
curl -fsSL -o /root/cloudkey.service \
https://raw.githubusercontent.com/jnovack/cloudkey/$TAG/cloudkey.service
curl -fsSL -o /root/cloudkey.env.example \
https://raw.githubusercontent.com/jnovack/cloudkey/$TAG/cloudkey.env.exampleOn the device, don't purge the ck-ui package (see danger zone
above) — just make sure its service is stopped/disabled (Step 1 already
did this), then install the replacement:
install -m 755 /root/cloudkey /usr/local/bin/cloudkey
install -m 644 /root/cloudkey.service /lib/systemd/system/cloudkey.service
[ -f /etc/cloudkey.env ] || install -m 644 /root/cloudkey.env.example /etc/cloudkey.env
systemctl daemon-reload
systemctl enable --now cloudkeyCheck it actually opened the framebuffer, not just that the process is running:
journalctl -u cloudkey -n 20You should see it report the correct resolution (160×60 on this
hardware) rather than an error opening /dev/fb0.
Packaged version:
scripts/runbook/phase1-cloudkey-install.sh,
deployed at /usr/local/bin/phase1-cloudkey-install.sh, wires up the
whole download-and-install sequence above. Safe to re-run — it won't
overwrite a /etc/cloudkey.env you've already hand-edited.
The application packages leave their state behind:
# check these are empty/unwanted first
rm -rf /data/unifi /data/uos /data/autobackupIf you kept postgresql running for your own use (its own package is
stock Debian/PGDG, not Ubiquiti's — no reason to remove it just because
unifi-core used it), drop only the UniFi-created databases and roles,
not the server:
sudo -u postgres dropdb 'unifi-core'
sudo -u postgres dropuser 'unifi-core'
sudo -u postgres dropdb 'unifi-identity-update'
sudo -u postgres dropuser 'unifi-identity-update'(Check sudo -u postgres psql -c '\l' first for the actual list on your
device — package versions differ.)
If you already removed PostgreSQL by hand before purging the UniFi
agents, some old package postrm scripts may complain that they cannot
drop their databases. That is noisy but expected until every config-only
package has been purged; the ordering above avoids creating that state on
a fresh run.
Remove the orphaned UniFi service accounts. apt purge removes a
package's files but deliberately never deletes the system users its
postinst created — dpkg can't know whether that UID owns files
elsewhere, so it leaves user removal to you. After the purge you're left
with roughly two dozen orphaned appliance accounts (unifi-drive*,
unifi-protect*, unifi-talk, apollo, ds, ms, ucs-*, uos-go,
fabric-agent, …) plus a couple of member-less groups (unifi-streaming,
unifi-talk). They're all nologin, password-locked, key-less, and their
packages are gone — inert clutter, but clutter worth removing. Confirm the
scope first:
# accounts in the appliance namespace still present
awk -F: '$1 ~ /unifi|ucs|uos|apollo|fabric|^ds$|^ms$/{print $1}' /etc/passwdEach is safe to deluser once you've confirmed it owns no files, has no
process, no password, and no authorized_keys. Two accounts are the
exception and must be left in place:
-
ui— it's a UID-0 alias ofroot, which looks alarming, but it's created byck-ui's postinst, andck-uiis one of the two packages the danger zone above says never to remove. Deleting the account just invites its postinst to recreate it on the nextck-uiupdate. A locked, key-less root alias is inert; leave it. -
postgres— it owns a real data directory (handled by the database cleanup just above), so it isn't an orphan.
Packaged version:
scripts/runbook/phase1-account-cleanup.sh,
deployed at /usr/local/bin/phase1-account-cleanup.sh, does exactly this —
it works from a fixed candidate list (the superset seen across Cloud Key
models) but gates every removal behind per-account guards, so an account
still in use on a given box is skipped with a warning rather than deleted,
and ui/postgres are never candidates. Run
phase1-account-cleanup.sh --dry-run to preview, then -y to apply. It's
idempotent (already-removed accounts report as "already gone") and cleans
up member-less appliance groups and orphaned /var/log dirs the accounts
left behind in the same pass. It scans /var even when that's a separate
filesystem (it prunes /volume and the pseudo-filesystems rather than
using find -xdev, which would miss an account's logs on a box where
/var is its own mount).
Packaged version: Steps 0, 1, 2, and 4 above are all wired up as
scripts/runbook/phase1-purge.sh, deployed on the box
at /usr/local/bin/phase1-purge.sh (see File manifest below) — run
phase1-purge.sh to do all of the above in one pass, with Step 0's
simulate check as a hard gate (it aborts loudly if a forbidden package
like ck-ui/ubnt-tools would be removed) and a real liveness check
(sshd still active and listening on :22) after every batch in Step 2.
It builds the purge set from the target packages still present on this
box, so it is safe to re-run after a partial manual cleanup or a previous
aborted attempt. It deliberately runs on the box itself, not
orchestrated from your own machine — no assumption that whatever you're
SSH'd in from has bash or scripting tooling available.
Packaged version: Step 3 is a separate install, not a purge. Use
scripts/runbook/phase1-cloudkey-install.sh
for that instead (see its own "Packaged version" callout above for what
it does). The walkthrough above is the reference for what this script
does and why; the script exists for when you don't need the explanation
again.
This is the real test of whether the removal was actually clean, not just apparently successful:
reboot
# wait, then:
ssh root@<device-ip> uptime
ip -4 addr show eth0 # confirm DHCP renewed
systemctl --failed # should be empty (or explainable)Expect a couple of orphaned units to show up as failed here, harmlessly:
-
nginx.service— if it was only ever configured as a reverse proxy in front ofunifi-core's web UI, its config will reference a log path under/data/unifi-core/...that no longer exists. Nothing needs nginx running anymore unless you plan to reuse it for something else. - Any UI/telemetry-reporting daemon whose whole job was exporting status for the now-removed console — it'll crash-loop once its targets are gone. Disable it; it's monitoring, not anything load-bearing.
Clear these once you've confirmed they're expected:
systemctl disable --now nginx <telemetry-daemon>
systemctl reset-failedIf you deployed a front-panel replacement, confirm it came back on its own, without a manual restart — that's the actual proof the service survives a real boot, not just a warm process.
Packaged version: the checks above (minus the reboot itself, which
can't script its own recovery — a script dies with a box that fails to
come back) are wired up as
scripts/runbook/phase1-verify.sh, deployed at
/usr/local/bin/phase1-verify.sh. Reboot by hand, reconnect, then run
phase1-verify.sh to run through all of the above without relying on
memory for what to check.
This is the one step in the whole process that's genuinely low-risk, because it removes nothing. Native UniFi OS devices already point at real Debian repos — check first, don't assume:
cat /etc/apt/sources.listOn a bullseye-based device you should see something like:
deb https://deb.debian.org/debian/ bullseye main contrib non-free
deb https://deb.debian.org/debian/ bullseye-updates main contrib non-free
deb https://archive.debian.org/debian/ bullseye-backports main
deb https://security.debian.org/debian-security bullseye-security main contrib non-free
Seeing archive.debian.org for bullseye-backports while
deb.debian.org still serves the main suite is normal — backports gets
archived faster than mainline, it doesn't mean the release is dead. If
your device is genuinely missing proper Debian sources (older UniFi OS
builds sometimes are), add them yourself before continuing.
Even for a same-release patch cycle, simulate first — old habits:
apt-get update
apt-get full-upgrade --simulateUnlike the application purge, a same-release upgrade removes nothing
(check the simulate output says 0 to remove), so none of the
dependency-cascade danger from Step 2 applies here. If the simulate output
is clean, run it for real:
apt-get full-upgrade -y
apt-get --purge autoremove -yTwo things worth watching for:
-
Your system may self-report as
oldoldstableor similar in the upgrade output — that's apt telling you how far behind current Debian stable this release now is, not an error. It just means the ceiling for this step is low: only security-relevant packages get patched on an old release, so don't expect a large upgrade list. On a bullseye device in 2026, expect somewhere around a dozen packages (core tools likedpkg,openssl,util-linux), not a sweeping refresh. -
Watch for Ubiquiti-custom builds getting replaced by stock ones as a
side effect.
jq/libjq1on this device were a custom~ubntbuild; the normal security upgrade replaced them with Debian's own patched build automatically. That's a welcome bonus, not something you need to handle separately — it's just what "install packages your Debian repos give you" naturally does once the apt sources are correct.
Confirm you're fully caught up:
apt list --upgradable # should be emptyNote
This is not the same as a release upgrade. If you want genuinely current package versions across the board — not just security patches on 2021-vintage bullseye — that means moving to a newer Debian release (bullseye → bookworm → trixie), which is a substantially bigger and riskier undertaking: new kernel ABI, new systemd version, and unknown compatibility with the board's still-installed custom kernel/initramfs/udev packages from the danger zone above. Treat that as a deliberate, separate decision, not something to bundle into routine patching.
Install the base tooling later phases assume. The stock image is missing a couple of packages that every later phase takes for granted, so put them on now rather than mid-phase — the box then has a complete baseline in one place:
apt-get install rsync nfs-common-
rsync— file sync, used later to offload completed downloads to another host. -
nfs-common— the NFS (Network File System) client (mount.nfs/mount.nfs4), for mounting a remote share to sync into. It pullsrpcbind,keyutils, andlibnfsidmap2; that's expected, not bloat.
A base-release install like this removes nothing (same as the upgrade above), so it carries none of Step 2's dependency-cascade danger.
Packaged version: scripts/runbook/phase1-base-tools.sh,
deployed at /usr/local/bin/phase1-base-tools.sh, runs the simulate +
install + a presence check, and is safe to re-run as a "is the baseline
here?" check.
The Gen2 Plus's whole reason for existing over smaller Cloud Key models is
the internal 2.5" drive bay. If your device has no bulk-storage bay, or no
drive currently appears in lsblk, skip the format/mount commands and
move on; later phases that need storage should either mount removable
media explicitly or use a configurable path. On a Gen2 Plus where
/volume (or whatever the UniFi storage-mount partition was called on
your device) shows up empty and healthy, this step is simple: pick a
filesystem and mountpoint and move on. It wasn't simple here, and the
troubleshooting path below is worth documenting in full because none of
it was caused by anything in the steps above — it's worth knowing about
on any device with a USB-attached (rather than native SATA) drive bay.
The happy path — installing a new or replacement drive that's healthy. Don't assume the drive is blank just because it's new to this box; wipe it unconditionally rather than inspecting what might already be on it:
lsblkConfirm the drive shows up as sda (or whatever name it takes) at
roughly its full advertised capacity — the transport/media symptoms in
the troubleshooting section below only apply to a struggling drive, not
this path.
Warning
wipefs and mkfs below destroy everything on the target drive.
Double-check the device name (sda vs. any other disk you have attached)
before running either command — there's no confirmation prompt and no
undo.
Then clear any existing partition table or filesystem signature:
wipefs -a /dev/sdaFormat the whole disk device directly as ext4 — deliberately no
partition table (the "superfloppy" pattern; keeps lsblk/blkid
simple and matches how this box's drive has been set up each time it's
been replaced so far), labeled to match:
mkfs.ext4 -F -L volume /dev/sdaMount it at /volume (this device's established convention for the
UniFi storage partition, kept for continuity even after de-Ubiquitizing).
Important
Don't use /etc/fstab for this. Confirmed by direct testing
(2026-07-13, two full reboot cycles): this board still runs a live
UniFi bootup-hook framework (owned by the load-bearing
cloudkey-plus-apq8053-base-files/ubnt-tools packages, kept installed
on purpose — see the danger-zone section above) that silently resets
/etc/fstab to a minimal template on every boot, dropping any manually
added line with no error or warning. A plain systemd .mount unit under
/etc/systemd/system/ isn't touched by this and has now survived
multiple real reboots, so use that instead:
UUID=$(blkid -s UUID -o value /dev/sda)
cat > /etc/systemd/system/volume.mount <<EOF
[Unit]
Description=Bulk storage drive (whole-disk ext4, no partition table)
[Mount]
What=/dev/disk/by-uuid/$UUID
Where=/volume
Type=ext4
Options=defaults,noatime
[Install]
WantedBy=local-fs.target
EOF
systemctl daemon-reload
systemctl enable --now volume.mountConfirm it actually took:
systemctl is-active volume.mount
mount | grep /volume
lsblkIf /volume comes back mounted to the right drive, you're done — skip
the rest of this step, which only applies if the drive turns out to be
unhealthy instead.
A second landmine from the same source, worth knowing before you move
on to Phase 2: a separate piece of that same still-active hook
framework (mp-clean volume, run from /usr/lib/ubnt/hooks/system/bootup-{top,bottom}/*-srv-link)
deletes any empty, plain-alphanumeric-named subdirectory directly
under /volume on every boot — it's meant to clean up stale native-UniFi
partition mountpoints, but it can't tell those apart from ordinary
subdirectories like tv/movies that just happen to be empty. See
Phase 2's directory-recreation step for the .keep-file
workaround this requires.
Packaged version: every command above is also wired up as
scripts/runbook/phase1-format-mount-volume.sh,
deployed on the box at /usr/local/bin/phase1-format-mount-volume.sh (see
File manifest below) — run phase1-format-mount-volume.sh /dev/sda to do
all of the above in one shot (it prompts for confirmation before wiping;
pass -y as a second argument to skip that for a non-interactive run).
The walkthrough above is the reference for what it does and why; the
script exists for when you don't need the explanation again.
Symptom: the drive didn't show up in lsblk at all — not unmounted,
not failing to mount, just absent.
First finding: it was being probed at boot, per dmesg — it's
attached over USB through a bridge chip, not native SATA:
dmesg | grep -iE 'sda|scsi|uas|usb.*storage'Look for a New USB device found line with an idVendor/idProduct, and
whatever bridge chip identifies itself over SCSI INQUIRY. On this device
it was an ASMedia ASM1153E (USB ID 174c:1153).
Second finding: the drive span up (slowly — over a minute), then failed reading sector 0 with a "critical target error," a command abort, and a bus reset that itself failed — which is why the device vanished instead of just failing to mount. The actual kernel message that pins this down:
xhci-hcd: ERROR Transfer event for disabled endpoint or incorrect stream ring
This is a known category of bug: USB Attached SCSI (UAS) uses USB3 stream endpoints, and some xHCI host-controller implementations don't handle them reliably with certain bridge/drive combinations. It is not a media failure signature — that distinction matters for what comes next.
The fix, applied live, no reboot required. uas and usb_storage are
commonly compiled directly into embedded kernels rather than built as
loadable modules — check with lsmod. If neither shows up there, the
usual modprobe blacklist approach won't apply, but the usb_storage
driver's quirks parameter is still exposed and writable even when it's
built in:
echo '<idVendor>:<idProduct>:u' > /sys/module/usb_storage/parameters/quirksThe u flag tells the uas driver to back off and let usb_storage
claim the device instead, using the older Bulk-Only Transport instead of
UAS — sidestepping the stream-endpoint bug entirely. This only takes
effect on the next enumeration, not an already-dropped device, so it
needs a physical reseat (unplug/replug the drive) to actually apply. After
that: scsi host2: usb-storage ... in dmesg confirms the correct driver
bound, and the transport error is gone for good.
But that only fixed one of two problems. Even with the correct driver now used, reads at sector 0 and at a sector right near the very end of the disk — exactly where a GPT partition table's primary and backup headers live — failed with a distinctly different error:
Sense Key : 0x3 [current] # MEDIUM ERROR
ASC=0x11 ASCQ=0x0 # Unrecovered read error
That's a genuine media-level failure, not a transport quirk — and it's why
parted /dev/sda print reports "unrecognised disk label" even after the
transport fix.
A natural next question: does SMART confirm it? Not on this
configuration — smartctl -a /dev/sda correctly identifies the drive
(model, serial, firmware) but every SMART command aborts at the SCSI
layer, with or without -T permissive or -d sat. Many USB-SATA bridges
simply don't implement ATA passthrough for SMART in Bulk-Only Transport
mode, even when they support it better in UAS mode — meaning the transport
fix above can cost you SMART visibility as a side effect. Worth knowing
before you assume SMART will confirm anything either way.
Last resort tried: consumer drives typically only remap a bad sector
on a write to it, not a read, so a fresh partition table write was worth
attempting — there's no data at risk if /volume was already confirmed
empty:
sgdisk --zap-all /dev/sdaThis failed too, with Error 5 (I/O error) on both the GPT header and MBR
writes, and the same medium errors recurring in dmesg during the
operation.
Where this leaves you: three independent methods (the original failing read, the same read after fixing the transport bug, and an actual write attempt) all confirm real, persistent, unrecoverable errors at both ends of the disk. That's a hardware conclusion, not a software one — the transport bug is fixed and worth applying regardless, but a drive (or bridge board) exhibiting this pattern needs physical inspection or replacement, not another software workaround. If you hit this, don't spend more time on it in software once you've confirmed the same failure persists across a transport fix and an actual write attempt — that's the point where it stops being a Linux problem.
Out of the box you administer this device as root, and depending on how
it shipped, password login may still be reachable. Before it starts doing
real work, give it a proper access posture: an unprivileged admin user for
day-to-day use, and no password logins anywhere. The wrinkle specific to
this hardware is that it lives on the end of a PoE cable with no keyboard —
so "no password logins" needs an escape hatch for the day you lose your SSH
key, and that escape hatch is the rear-panel reset button.
The design is two states you flip between:
-
Safe state (the normal one).
root,cloudkey(andubntif your device has one) have their passwords locked — no password login at all — and SSH accepts keys only. You log in ascloudkeyandsudowithout a password;rootremains reachable by key as a fallback. - Emergency state. A reset-button hold flips SSH back to allowing root password login and unlocks root's existing password, so someone with physical access who has lost their key can still get in. Once back in, you flip to the safe state again by hand.
The reset-button half lives in a separate front-panel app (it's what drives
the LCD and watches the button); all it needs from this phase is that the
two toggle scripts exist at a stable path — /usr/local/sbin/security-lock.sh
and /usr/local/sbin/security-unlock.sh.
Create the admin user with passwordless sudo and a key. Locking a
password never blocks key-based SSH — passwd -l only invalidates the
password hash, which key auth doesn't consult — so the safe state is only
safe from lockout if cloudkey has a working key first. Copy the key you
already administer the box with (root's authorized_keys) so applying the
lockdown can't strand you:
useradd -m -s /bin/bash cloudkey
usermod -aG sudo cloudkey
# passwordless sudo, via a drop-in validated before it's installed
echo 'cloudkey ALL=(ALL) NOPASSWD:ALL' > /tmp/cloudkey.sudoers
visudo -cf /tmp/cloudkey.sudoers && \
install -m 0440 -o root -g root /tmp/cloudkey.sudoers /etc/sudoers.d/cloudkey
# bootstrap cloudkey's key from root's so the key-only state can't lock you out
install -d -m 700 -o cloudkey -g cloudkey /home/cloudkey/.ssh
cp /root/.ssh/authorized_keys /home/cloudkey/.ssh/authorized_keys
chown cloudkey:cloudkey /home/cloudkey/.ssh/authorized_keys
chmod 600 /home/cloudkey/.ssh/authorized_keysWarning
Once you lock these passwords and switch SSH to key-only below, losing
the cloudkey key means losing remote access — recovery falls back to
the rear-panel reset button (see the emergency toggle further down). If
you have any doubt the bootstrap above actually worked, open a fresh
ssh cloudkey@<device-ip> session and confirm it logs in before
running the block below.
Apply the safe state. Lock the privileged accounts' passwords and drop in a key-only SSH policy:
for u in root ubnt cloudkey; do id "$u" >/dev/null 2>&1 && passwd -l "$u"; done
cat > /etc/ssh/sshd_config.d/10-security.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin prohibit-password
EOF
sshd -t && systemctl reload sshThe device's sshd_config puts Include /etc/ssh/sshd_config.d/*.conf on
its first line, and sshd takes the first value it sees for each option, so
this drop-in overrides the stock config below it — no need to edit
sshd_config itself.
Important
One device-specific quirk is worth knowing, because getting it wrong
leaves a silent hole rather than an error: bullseye ships OpenSSH
8.4, which honors the old option name
ChallengeResponseAuthentication; OpenSSH renamed it to
KbdInteractiveAuthentication in 8.7. Set both. On 8.4, setting only
the new name parses cleanly (so sshd -t passes and nothing warns you)
but does nothing — and with UsePAM yes, a live keyboard-interactive
method can tunnel a password login even though PasswordAuthentication
is no. Both lines set to no closes that on either version.
Confirm the safe state took, and that cloudkey really can get in and
escalate:
sshd -T | grep -iE 'permitrootlogin|passwordauthentication|kbdinteractive|challengeresponse'
passwd -S root cloudkey # both should show 'L' (locked)
ssh cloudkey@<device-ip> 'sudo -n whoami' # -> root, no promptThe emergency toggle. security-unlock.sh is the reverse: it unlocks
root's existing password (passwd -u root — it never sets one, so if root
had no password this grants nothing) and rewrites the same drop-in to allow
root password login. security-lock.sh puts everything back. Both validate
with sshd -t before reloading, so a bad edit can't wedge sshd.
Packaged version: all of the above is wired up as three scripts
deployed to /usr/local/sbin/ (see File manifest below).
scripts/runbook/phase1-security-setup.sh does
the one-time setup — creates cloudkey, installs the sudoers drop-in,
bootstraps the key (or takes one as its first argument:
phase1-security-setup.sh 'ssh-ed25519 AAAA...'), and then execs
security-lock.sh. It refuses to lock down if cloudkey would end up
with no key, so a bad run can't strand you. The two toggles —
scripts/runbook/security-lock.sh and
scripts/runbook/security-unlock.sh — are the safe/
emergency flip, idempotent and safe to re-run, and are the stable entry
points the rear-panel reset-button handler calls. The walkthrough above is
the reference for what they do and why.
Everything created by this guide, for a final checklist:
| Path | Purpose |
|---|---|
/usr/local/bin/phase1-purge.sh |
Steps 0/1/2/4 — simulate-gated batched purge + cleanup; source of truth is scripts/runbook/phase1-purge.sh in this repo |
/usr/local/bin/phase1-verify.sh |
Step 5's post-reboot checklist, scripted; source of truth is scripts/runbook/phase1-verify.sh in this repo |
/usr/local/bin/phase1-account-cleanup.sh |
Step 4 — removes the orphaned UniFi service accounts/groups the purge leaves behind (guarded; never touches ui/postgres); source of truth is scripts/runbook/phase1-account-cleanup.sh in this repo |
/usr/local/bin/phase1-base-tools.sh |
Step 6 — installs rsync + nfs-common baseline; source of truth is scripts/runbook/phase1-base-tools.sh in this repo |
/usr/local/bin/phase1-format-mount-volume.sh |
formats + mounts a replacement drive at /volume (Step 7); source of truth is scripts/runbook/phase1-format-mount-volume.sh in this repo |
/etc/systemd/system/volume.mount |
mounts /volume at boot — generated fresh (new UUID) by the script above on every drive replacement; not /etc/fstab, which this board silently resets on every boot |
/usr/local/bin/phase1-cloudkey-install.sh |
Step 3 (optional) — downloads + installs the front-panel LCD replacement; source of truth is scripts/runbook/phase1-cloudkey-install.sh in this repo |
/usr/local/bin/cloudkey |
Step 3 — the LCD-replacement binary itself, pulled pre-built from jnovack/cloudkey's latest GitHub release |
/lib/systemd/system/cloudkey.service |
Step 3 — its systemd unit |
/etc/cloudkey.env |
Step 3 — its config (tunnel/app rows for the OLED); created empty-ish from the upstream example, edit by hand |
/usr/local/sbin/phase1-security-setup.sh |
Step 8 one-time setup — creates cloudkey, passwordless sudo, key bootstrap, then execs the lock; source of truth is scripts/runbook/phase1-security-setup.sh
|
/usr/local/sbin/security-lock.sh |
Step 8 — flip to the safe state (passwords locked, key-only SSH); source of truth is scripts/runbook/security-lock.sh; stable path the reset-button handler calls |
/usr/local/sbin/security-unlock.sh |
Step 8 — flip to emergency state (root password login); source of truth is scripts/runbook/security-unlock.sh; called by the reset-button handler |
/etc/sudoers.d/cloudkey |
passwordless sudo for cloudkey (0440, visudo-validated) |
/etc/ssh/sshd_config.d/10-security.conf |
the single managed SSH policy drop-in that lock/unlock overwrite in full — safe vs. emergency state is whatever this file currently says |
A stock, fully-patched Debian 11 ARM64 box: SSH access hardened to keys
only with an unprivileged cloudkey admin user (Step 8) and a
reset-button escape hatch for the day you lose your key, a working
front-panel LCD (if you replaced it), power-loss protection independently
verified intact through all of the above, roughly 15 fewer packages than
it started with, and nothing left in apt list --upgradable. The internal
drive bay's fate depends entirely on what you find in Step 7 — a healthy
drive just needs a filesystem and a mountpoint; a struggling one, like the
one documented above, needs a replacement before it's useful for anything.
Whether to stay on a fully-patched bullseye or pursue a full release upgrade for genuinely current software (see Step 6's closing note) is the one deliberate decision left on the table, worth making on its own rather than as an afterthought of cleanup. Everything else here — what you actually run on the box once it's a stock Debian server — is up to you.
Nobody documents doing exactly this — hand-purging the platform layer in one shot while keeping the box stable. What's actually out there is more conservative:
- The community-documented "safe" removal list is only the bolt-on
Applications (Access, Connect, Protect, Talk, UID) via
apt purge, followed by a normalapt update && upgrade && full-upgrade && autoremovecycle — not the core platform (unifi-core,unifi,mongodb-server,ustd,ubnt-tools). - One well-known "headless Linux server" conversion guide uses the
device's own System Settings → Updates → Uninstall UI for Network/Protect
rather than raw
apt purgeof platform packages, then repoints/etc/apt/sources.listat real Debian repos and upgrades through Debian versions — gradually outgrowing the Ubiquiti base rather than surgically excising it. That's a legitimate, more conservative alternative to everything above if you'd rather not touch the platform packages directly at all. - A lighter-weight, SSH-accessible recovery option worth knowing about
before you need it: Ubiquiti's own
ubnt-systool fwupdate <url>andubnt-systool reset2defaults— useful if SSH is still alive but something's gone wrong, as a step short of a full physical firmware-recovery-mode restore.
- Every command above was run and verified on both a Cloud Key Gen2
Plus and a plain Cloud Key Gen2 (both Qualcomm APQ8053, aarch64).
The two behaved identically apart from the base-files package name (the
-plus-/-g2-split noted throughout) and the Plus's internal drive bay (Step 7) — the dependency chains, unit list, and purge set were the same on each. Other UniFi OS console hardware (UDM, UNVR, or the original 32-bit Gen2) may differ in package names or the ARM vs ARM64 distinction — verify withapt-cache dependsandapt-get purge --simulateon your own device before trusting this list verbatim. - Some sources report Ubiquiti removed the internal battery entirely in
later hardware revisions of this device — check whether yours actually
has one (
ls /sys/class/power_supply/) before assuming the power-loss chain above is present at all. - This whole process operates purely at the package/service layer on the already-booted Debian rootfs — it never touches the eMMC boot partition table, bootloader, or A/B slot mechanism. That's what keeps "reinstall packages" as the worst case instead of "bricked device," as long as the danger-zone packages above are respected.