Moving a Guacamole (or any stateful Docker stack) to a new host? Copy the volume, don't re-init

When you move a Guacamole stack between machines, the temptation is to spin up a fresh stack on the new host and let initdb.sql build the database. Don't — that gives you an empty install. Every saved connection, every user, and (critically) every TOTP/MFA enrollment lives inside the MariaDB data volume. Re-running the init script wipes all of it, and your users have to re-scan their authenticator QR codes.

The fix is a cold, byte-exact copy of the DB volume. Stop the stack first so the copy is consistent:

# on the OLD host
cd ~/path/to/guacamole && docker compose down
docker run --rm -v guacamole_db-data:/v -v /tmp:/out alpine \
  tar czf /out/guacdb.tgz --numeric-owner -C /v .

--numeric-owner matters: MariaDB's files are owned by uid 999 inside the container, and you want that uid preserved, not remapped to whatever user happens to exist on the new box.

Ship the tarball over, then restore it into a fresh named volume before the first up:

# on the NEW host
docker volume create guacamole_db-data
docker run --rm -v guacamole_db-data:/v -v /tmp:/in alpine \
  tar xzf /in/guacdb.tgz --numeric-owner -C /v
cd ~/path/to/guacamole && docker compose up -d

MariaDB's entrypoint checks whether the data directory is empty. Since you just populated it, it skips initialization entirely and comes up with all your data intact — confirm with docker logs guacamoledb | grep "ready for connections" and no Initializing database line.

One more thing worth doing while you're at it: if a reverse proxy fronts the app (e.g. nginx terminating TLS on a separate edge box), point its proxy_pass at a DNS name that tracks the new host's IP rather than the raw IP. Then a future move is a one-line edge change — or zero, if the name already follows the host. A quick sanity check that the whole path works, without needing to log in:

curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your.guac.example/api/tokens \
  -H "Content-Type: application/x-www-form-urlencoded" --data "username=x&password=y"

A 403 here is success — it means the webapp reached the database and rejected bad credentials. (A 500 usually just means you forgot the application/x-www-form-urlencoded content type, not that anything's broken.)

`gh` suddenly 401s over SSH after a reboot? Your token is locked in the keyring

After a reboot, every gh command returned HTTP 401: Requires authentication — even though you ran gh auth login ages ago. It looks like "the reboot wiped the auth."

The usual culprit: the token was saved in the system keyring (libsecret / gnome-keyring), and you log in over plain SSH (publickey). On an SSH login PAM never sees your password, so pam_gnome_keyring doesn't unlock the keyring — only a desktop/GUI login does that as a side effect. After a reboot the keyring stays locked, gh can't read the token, and you get a 401. It's not really about the reboot; it's that after the reboot no desktop login ever unlocked the keyring.

On a headless / SSH-only box, don't hand your credentials to the keyring. The simplest fix is an environment variable — gh reads it first and never touches the keyring or hosts.yml:

# ~/.bashrc.secret (sourced by login shells)
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx

Verify without printing the token — just check which account it resolves to:

gh api user -q .login      # prints your username on success

GH_TOKEN loads with every login shell, so reboots and pure SSH both stay stable. Clear the now-redundant credential in the keyring / hosts.yml (gh auth logout) to keep a single source of truth.

Broader lesson: on a headless machine, anything that assumes an interactive desktop session — keyring unlocking, a resident user-level systemd service — will bite you. Prefer session-independent mechanisms (environment variables, loginctl enable-linger).

A `--user` systemd service restarts every 5 minutes? Check logins, not cron

A systemctl --user service kept starting, then stopping ~12s later, every 5 minutes — flooding a notification channel each time. But crontab -l was empty and systemctl --user list-timers had no matching timer. So who was cycling it?

Usually it's not a scheduled job. A user-level service's lifetime is tied to the per-user systemd manager (user@<uid>.service), and without linger that manager only runs while the user has at least one login session:

  • Someone logs in (even a 2-second SSH/rsync) → the manager starts → it reaches default.target → your WantedBy=default.target service gets pulled up.
  • ~10s after the last session exits → the manager tears down → your service stops with it.
  • Next login repeats the whole dance.

So the service's "restart cadence" is really the login cadence. The system journal makes it obvious:

journalctl --since "-15min" | grep -iE "Accepted publickey|New session|Removed session|Reached target exit.target"

In my case the culprit was another box running */5 * * * * rsync … host:/backup/… — a short SSH connection every 5 minutes that lit up the entire user manager and dropped it again. The moment the host had a persistent session (a lingering tmux), the symptom vanished — which is the strongest tell: it only happens when nobody is logged in.

The fix is to detach the manager from login so it stays resident:

sudo loginctl enable-linger <user>
# verify
loginctl show-user <user> --property=Linger   # Linger=yes

With linger on, the manager starts at boot and no longer stops when sessions end, so the service becomes a real background daemon. For a user-level service that must run while no one is logged in, enable --now is not enough — enable-linger is the missing prerequisite.

重启后纯 SSH 登录,gh 突然 401?token 可能锁在 keyring 里

机器重启后,gh 命令全线 HTTP 401: Requires authentication,可你明明早就 gh auth login 过。奇怪的是,好像"重启一下授权就没了"。

真凶多半是:token 存进了 系统 keyring(libsecret/gnome-keyring),而你走的是纯 SSH(publickey)登录。SSH 登录时 PAM 拿不到你的登录密码,pam_gnome_keyring 不会解锁 keyring——只有桌面/GUI 登录才会顺带解锁。于是重启后 keyring 一直锁着,gh 读不出 token,直接 401。这跟"重启"本身无关,是"重启后再没有任何桌面登录去解锁 keyring"。

无头/纯 SSH 机器上,别把凭据托付给 keyring。最省心的是用环境变量,gh 优先读它,完全不碰 keyring、也不落 hosts.yml:

# ~/.bashrc.secret (被登录 shell source)
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx

验证时别把 token 打出来,看它解析到哪个账号就行:

gh api user -q .login      # 打印用户名即成功

GH_TOKEN 随登录 shell 加载,重启、纯 SSH 都稳。清掉 keyring/hosts.yml 里那份冗余凭据(gh auth logout)可保持单一来源。

更广的教训:无头机器上,任何依赖"交互式桌面会话"的机制——keyring 解锁、user 级 systemd 服务常驻——都会踩坑,优先选不依赖会话的方案(环境变量、loginctl enable-linger)。

user 级 systemd 服务每 5 分钟自己重启一次?先查登录会话,别急着找 cron

一个 systemctl --user 服务莫名每 5 分钟启动、十几秒后又停,反复刷屏。crontab -l 空的,systemctl --user list-timers 也没有对应 timer——那到底是谁在动它?

答案往往不是定时任务,而是 user 级服务的生命周期绑在 per-user systemd manager(user@<uid>.service)上。没开 linger 时,这个 manager 只在该用户"至少有一个登录会话"时存活:

  • 有人(哪怕一条 2 秒的 SSH/rsync)登录 → manager 启动 → 到达 default.target → 你那个 WantedBy=default.target 的服务被拉起。
  • 会话退出约 10 秒后 → manager 拆除 → 服务跟着停。
  • 下次登录再来一遍。

所以服务的"重启节奏"其实是登录节奏。用系统 journal 一看就露馅:

journalctl --since "-15min" | grep -iE "Accepted publickey|New session|Removed session|Reached target exit.target"

我这次的真凶是另一台机器上的 */5 * * * * rsync … host:/backup/…——每 5 分钟一条短 SSH 连接,把整个 user manager 点起来又放下。一旦本机有了常驻会话(比如一个挂着的 tmux),现象立刻消失——这就是最强的判定线索:只在"没人登录"时发生

治本是让 manager 脱离登录、常驻:

sudo loginctl enable-linger <user>
# 确认
loginctl show-user <user> --property=Linger   # Linger=yes

开 linger 后 manager 开机即起、不随会话退出而停,服务真正变成后台常驻。要让一个 user 级服务在无人登录时也长跑,enable --now 是不够的,enable-linger 才是前提。