Posts in category “Tips”

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).

重启后纯 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)。

Pinning a gh account with `GH_TOKEN="$(gh auth token --user X)"`? Check it's non-empty

A common pattern for scripts that must always hit GitHub as a specific account, regardless of whatever account is currently active, is:

GH_TOKEN="$(gh auth token --user work-account)" gh workflow run ...

This works — until gh auth token --user work-account fails and returns an empty string. GH_TOKEN="" is not treated as "unset" by your shell, but it is treated as unset by gh itself, which then silently falls back to whatever account is currently active in the keyring. If that's your personal account and the target repo belongs to an org it can't see, you get a confusing HTTP 404: Not Found — which reads like a missing-repo problem, not an auth problem.

Guard the lookup instead of trusting it inline:

TOKEN="$(gh auth token --user work-account)"
if [ -z "$TOKEN" ]; then
    echo "Error: could not obtain gh token for work-account" >&2
    exit 1
fi
GH_TOKEN="$TOKEN" gh workflow run ...

Same fix applies to any wrapper that routes gh calls by repo owner: resolve the token first, fail loudly if it's empty, and only then invoke the real gh binary. Silent fallback to the active account is the failure mode to design out.

Excalidraw's hand-drawn look is free — Excalidraw+ only buys you cloud collab

I almost passed on the cute hand-drawn flowchart style, assuming it sat behind a subscription. It doesn't. The hand-drawn aesthetic is Excalidraw's default and only style, and it's completely free. The paid tier, Excalidraw+, is team cloud collaboration and version history — nothing to do with how the diagrams look.

mermaid.live is the same story: the official open-source editor for Mermaid.js, free, no credits, no pay-per-render. The "credit-based" impression usually comes from third-party SaaS tools that generate Mermaid from a prompt.

So going from a flowchart to a hand-drawn version costs nothing. Get your flowchart as Mermaid (write it, or hand a vision-capable AI a photo of your sketch), then paste it straight onto the Excalidraw canvas — it detects the syntax and pops a "Parse as Mermaid" import dialog. There is no Insert → Mermaid item in the hamburger menu or toolbar (those only have Open / Save / Export / Live Collaboration); the other entry point is the command palette (Ctrl/Cmd + /) → search Mermaid.