refactor(server): make Ubuntu Server a profile of the unified Linux path#47
Merged
Conversation
platforms/server.sh reimplemented the whole install spine instead of reusing the shared engine. Fold it into linux_main (formerly desktop_main): one shared spine gated by a new SERVER_PROFILE bool, with desktop-only steps (pyenv/nvm/rust/pipx/pnpm/p10k/ghostty) skipped on the server and the server-only infra kept as an explicit step-two (server_extras/server_footer). The shared apt hooks (apt_install_tier, curl-based platform_tailscale_step and platform_docker_optional) move to lib/core.sh beside apt_bootstrap; arch.sh keeps its pacman overrides. server.sh drops from 222 to 188 lines. The headless zshrc/zsh_plugins overrides are unchanged (deploy_zshrc still wins on CONFIG_SRC_DIR). Validated: shellcheck clean, dryrun-smoke passes all four platforms, server dry-run has no desktop-only leakage and installs the Docker engine before the compose stacks; desktop output unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SERVER_PROFILE branch in linux_main returned before the --optional block, so 'setup.sh --profile server --optional' silently skipped the 6 low-priority server apt packages (net-tools, ffmpeg, exiftool, smartmontools, nmap, samba) that the old server.sh installed, and left server.sh's platform_install_tier 'low' case unreachable. Install them inside the server branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server-as-profile refactor deleted server.sh's `run_eval "$(custom_cmd docker-ce)"`, the entry's only consumer. Docker is now installed via the shared platform_docker_optional (hardcoded get.docker.com, same as the desktop docker entry), so docker-ce was dead data — and handled_by_setup:true meant it wasn't surfaced as a reminder either. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ubuntu.sh and server.sh had near-identical platform_install_tier copies that diverged only in whether the low tier installed snaps. That divergence was incidental (no low-priority server snaps exist) and would have silently dropped any future low server snap. Make the low tier run snap uniformly and hoist one shared copy into lib/core.sh beside apt_install_tier; arch.sh keeps its yay override. Package selection stays data-driven via package_manager.<platform> in packages.json, so a manager only installs where a package declares it -- no profile conditional needed in the install path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds HTTPS.md documenting the Tailscale-sidecar-per-service pattern that gives each service its own https://<service>.<tailnet>.ts.net URL (the only way to get subdomain-style names — a node has one MagicDNS name, so each service needs its own node). NPM cannot mint tailnet subdomains and is left orthogonal. Implements Forgejo as the reference: a tailscale sidecar terminates HTTPS via tailscale serve and shares its netns with Forgejo. Fixes the git breakage at its root — ROOT_URL moves from http://<domain>:3300 to https://<domain>/, SSH on :22. Updates the homepage Forgejo link to HTTPS and gitignores sidecar node state. Forgejo stays out of setup.sh's auto-start loop (needs TS_AUTHKEY/domain first); HTTPS.md documents the manual apply plus the rollout for the other services. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The linux-server zshrc/plugin overrides existed for reasons the shared dotfiles/zshrc.example already handles via guards: the notify hook no-ops without $DISPLAY/$WAYLAND_DISPLAY, version managers/zoxide are command-guarded, and fastfetch now keys off Ghostty *or* $SSH_CONNECTION. Delete both overrides so every platform deploys one zshrc + one plugin list, and the server gains Powerlevel10k for free (theme via antidote, ~/.p10k.zsh via the now-shared deploy). - core.sh: deploy zsh_plugins.txt + p10k.zsh in the shared path; server and desktop differ only in preclone vs ghostty - zshrc.example: fastfetch fires on SSH logins; document headless guards - update CLAUDE.md / linux-server docs for the removed overrides Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-essential (apt) is needed for cargo install (e.g. railguard); rust gains a server entry via rustup, matching the other Linux platforms. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTTPS.md gains the resolved auth-method decision (OAuth client + tag:container) plus two gotchas hit deploying the sidecar live: the ACL tag grant requirement, and a network-namespace-goes-stale issue when the sidecar is restarted after the app container is already running. TODO.md gets a tracked checklist for the remaining per-service rollout. create_ssh_key.sh's self-hosted SSH port was hardcoded to 2222 (Forgejo's old host-published mapping); now prompts with a 22 default, matching the sidecar setup verified live on ollie-server. Adds HANDOFF.md to track live infra state alongside code for session handoff. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ofile The server profile skipped nvm entirely, so claude-hud's statusline (which shells out to node) had nothing to run. Unify the nvm/node flow across desktop and server instead of branching, and round out the server package set with shellcheck and pytest to match the other platforms.
The gitea widget still pointed at localhost:3300, a host port that no longer exists since Forgejo moved behind a per-service Tailscale sidecar (network_mode: service:forgejo-ts, no published ports). Repoint it at the templated HOMEPAGE_VAR_FORGEJO_DOMAIN so the widget reaches Forgejo over the tailnet, matching the tile's href. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
47d38d8 added rustup to the server package set, but rust_toolchain_step was gated desktop-only, so the server installed rustup without ever running 'rustup default stable' -- cargo/rustc were unusable until a manual init. Ungate it (it self-guards on command -v rustup), matching how nvm now runs on both profiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GIT_SSH_PORT defaulted to 22 only inside the interactive prompt, so the env-driven path emitted a bare 'Port ' line. Since 22 is the SSH default, only emit the Port line for a non-standard port -- which also fixes the empty line and keeps custom-port self-hosted servers working. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TPS.md Scaffolds Portainer as the second service on the per-service HTTPS pattern: sidecar (hostname portainer) terminating HTTPS to the plaintext :9000 UI, app container on the shared netns with host ports dropped, plus .env and the homepage link. Portainer needs no app-side base-URL config (works at root). Enriches the HTTPS.md rollout table with an app-config column distinguishing the mechanical sidecar work from the per-app reverse-proxy settings (ntfy base-url, speedtest APP_URL, syncthing host-check), and ticks the resolved auth decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad gotcha Portainer's Tailscale sidecar was applied and verified on ollie-server, so tick it off in TODO.md. Document the homepage gotcha hit during rollout: env_file is baked into the container at creation, so `docker compose restart homepage` doesn't pick up a new HOMEPAGE_VAR_<SVC>_DOMAIN — needs `up -d` to recreate. Also fold that step into the five-mechanical-edits checklist for future services. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Third service on the per-service HTTPS pattern: sidecar (hostname uptime-kuma) terminating HTTPS to :3001, app on the shared netns with host ports dropped, env + homepage link. No app-side config — works at root. Also moves the uptime-kuma homepage *widget* url off http://localhost:3001 to the HTTPS domain, and fixes the now-stale HTTPS.md/TODO.md guidance that said widget urls stay on localhost: once the host port is dropped, localhost no longer resolves from the host-networked homepage container, so the widget must reach the service over the tailnet (this is what the Forgejo widget already does live). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fourth service on the per-service HTTPS pattern. Two service-specific notes: - The serve target is container port 80, NOT the 8765 host mapping (8765:80) -- unlike the prior services where host and container ports matched. Documented the container-vs-host-port distinction in HTTPS.md so it doesn't bite again. - Laravel needs APP_URL=https://speedtest.<tailnet>.ts.net for correct asset/ links; updated .env.example. Homepage href + speedtest widget url moved to the HTTPS domain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fifth service on the per-service HTTPS pattern. ntfy maps 5080:80, so the serve target is container :80 (same host-vs-container distinction as speedtest-tracker). App config: NTFY_BASE_URL=https://ntfy.<tailnet>.ts.net (else web/notification links break) and NTFY_BEHIND_PROXY=true so ntfy trusts X-Forwarded-For from the serve proxy for visitor identification. Homepage href moved to the HTTPS domain (ntfy has no widget). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both were applied and verified on ollie-server: uptime-kuma proxies cleanly, speedtest-tracker's APP_URL redirect confirms the HTTPS domain is wired correctly. Tick off both in the rollout checklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… herring ntfy was applied and verified on ollie-server. Its /config.js and /v1/config always report base_url empty by design (ntfy hardcodes it so the web app uses window.location.origin) — that cost real debugging time before confirming NTFY_BASE_URL was actually applied via the Matrix discovery endpoint instead. Document the real verification method so the next deploy doesn't repeat it. Also sync the rollout table status column, which had gone stale. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sixth service on the per-service HTTPS pattern. filebrowser maps 8080:80, so the serve target is container :80 (same host-vs-container note as speedtest/ntfy). No app-side config — works at root. Homepage href moved to the HTTPS domain (no widget). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied and verified on ollie-server: sidecar joined the tailnet, app responds 200 at root with no extra config needed. Tick off in the rollout checklist and sync the HTTPS.md status column. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Seventh service — the first with raw non-HTTP ports to preserve. GUI (8384) goes through tailscale serve; the sync/discovery ports (22000 tcp+udp, 21027 udp) are published on the SIDECAR (it owns the netns, so the app can't declare its own ports) and stay raw TCP/UDP — peer sync must not go through serve. Host-check fix: STGUIADDRESS=127.0.0.1:8384 disables Syncthing's Host-header check (which would reject the proxied hostname) and pins the GUI to loopback so only the serve proxy reaches it. Homepage href + customapi widget url (/rest/ system/status) moved to the HTTPS domain; API key unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied and verified on ollie-server: GUI loads clean over HTTPS (STGUIADDRESS alone avoided the Host check error, no extra GUI toggle needed), homepage widget's REST status call works, and sync/discovery ports are confirmed still host-published on the sidecar rather than proxied. Tick off in both checklists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…riant) Eighth service, and a new variant. glances must keep network_mode: host (+ pid: host + privileged) to read the host's real interfaces/processes, so it can't join the sidecar's netns without degrading what it measures. Instead the sidecar runs in its own netns and proxies to the host's :61208 via host.docker.internal (host-gateway); glances itself is unchanged. Because glances keeps its host port, its homepage widget url stays localhost:61208 (only the href moves to HTTPS) — opposite of the netns-shared services. Documented the host-networked variant in HTTPS.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied and verified on ollie-server: sidecar proxies via host.docker.internal to the unchanged host-networked glances container, preserving its monitoring fidelity. Tick off in both checklists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ninth and last container service. Split-port shape like syncthing: web UI (container :80, not the 8083 host map) via tailscale serve; DNS :53 tcp+udp published on the sidecar and kept raw for LAN clients. AdGuard publishes no :443, so there's no DoH conflict with the sidecar's serve. Dropped the first-run 3003:3000 wizard mapping (already configured; documented how to re-add temporarily). Homepage href + adguard widget url moved to the HTTPS domain; user/pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied and verified on ollie-server: sidecar live, UI redirects cleanly at root, and DNS :53 confirmed still resolving after the container recreate (no LAN DNS outage). Also verified the homepage widget's basic-auth credential against AdGuard's /control/status directly. Tick off in both checklists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cockpit is a host systemd service (not a container) on :9090 with self-signed TLS, so this is a sidecar-ONLY stack — no app container to share a netns with. The sidecar (hostname cockpit) proxies https+insecure://host.docker.internal:9090 to the host's Cockpit, giving https://cockpit.<tailnet>.ts.net. Cockpit rejects proxied requests from an unrecognized Origin (its host-check), so cockpit.conf.example ships the [WebService] Origins allow-list to drop into the host Cockpit config. Homepage href moved to the domain; the local ping stays (cockpit still listens on host :9090). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TPS sidecar
atvloadly was never tracked here — it lived in an untracked
/home/ollie/docker-compose.yml, the only service on the homepage dashboard
without a tracked compose file. Migrated it into linux-server/atvloadly/ and
applied the same sidecar pattern as every other service.
Apple TV discovery goes through the host's avahi-daemon via bind-mounted
sockets, not the container's own network namespace, so sharing netns with the
sidecar doesn't affect mDNS/Bonjour pairing — confirmed via logs
("Avahi discovery started...", no errors) after migration. Hit one new
gotcha: `hostname:` on the app container conflicts with
`network_mode: service:...` and had to be dropped (the sidecar's hostname
already sets the tailnet name). Verified live at
https://atvloadly.tail01d63b.ts.net/, homepage link updated, old untracked
compose file deleted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… variant) Last service in the rollout. homepage keeps network_mode: host (it reaches the host-networked helpers — tailscale-proxy widget :8089 and glances :61208 — via localhost), so it uses the glances/cockpit host-gateway variant: a homepage-ts sidecar in its own netns proxies https://homepage.<tailnet>.ts.net to the host's :3000 via host.docker.internal. The new domain is appended to HOMEPAGE_ALLOWED_HOSTS or homepage rejects the proxied request (its reverse-proxy host-check). Homepage's own dashboard tile href moved to the HTTPS domain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied and verified on ollie-server. The documented cockpit.conf.example Origins step turned out unnecessary in practice: hand-crafted WebSocket upgrades to /cockpit/socket show 101 with a matching Origin and 403 with a foreign one, proving Cockpit already derives the allowed origin from the proxied request's Host/X-Forwarded-Proto. Saves a manual sudo step for future deploys. Tick off in both checklists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Applied and verified on ollie-server: sidecar live, HOMEPAGE_ALLOWED_HOSTS confirmed to include the new domain, container healthy post-recreate. With this, every service in the rollout table is done except nginx-proxy-manager (optional). Tick off in both checklists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents what the tailnet HTTPS layer depends on (hosted control plane, MagicDNS, auto-certs, DERP) vs what's open (WireGuard data plane, the client), the outage vs permanent-shutdown failure modes, and the Headscale + own-domain exit path (with NPM as the vendor-independent bridge). Also documents the tailscaled SPOF (keep LAN SSH) and how to share a container on the LAN without Tailscale by publishing a host port on the sidecar (coexists with the serve :443). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep NPM for trusted-cert HTTPS to clients that can't/won't join the tailnet (LAN TVs, consoles, guests — e.g. a Plex client), since *.ts.net is trusted only on the tailnet and plaintext LAN HTTP trips the browser's 'not secure' warning. Documents the mechanism: a real public domain + NPM wildcard Let's Encrypt cert via DNS-01 (no public exposure needed) + AdGuard split-horizon rewrite of *.home.<domain> to the LAN IP. Notes the Plex caveat (native *.plex.direct TLS usually bypasses a reverse proxy). Fixes the stale README line that said NPM terminates homepage HTTPS (the homepage sidecar does now). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The homepage Tailscale tile linked plain http://<ip>:5252 — tailscale web's own local node management UI, a host systemd user unit, not a container. Added linux-server/tailscale-web/, a sidecar-only stack like Cockpit. tailscale web needed two ExecStart flags on its systemd unit: --listen 0.0.0.0:8088 (its default is localhost-only, which host.docker.internal can't reach since loopback is per-network-namespace) and --origin https://tailscale-web.<tailnet>.ts.net (without it, the app redirects browsers to its own bare ip:port — harmless over plain HTTP, but a hard SSL_ERROR_RX_RECORD_TOO_LONG once the tile is HTTPS, since the browser inherits https: from the page and tries to TLS-handshake a plain-HTTP port). Verified end-to-end: 200, no redirect, real page content, confirmed in browser. Don't proxy to port :5252 — something else unrelated answers there (survives reboots, never reflects this unit's config), which is what produced the redirect loop while debugging. Documented in HTTPS.md Gotchas. Also dropped the now-orphaned HOMEPAGE_VAR_TAILSCALE_IP var — its only use was the href this commit replaces. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lises-c.me Gives NPM's admin UI a tailnet HTTPS front door at https://npm.<tailnet>.ts.net, consistent with the other services. NPM is the non-tailnet edge so it must keep binding host :80/:443/:81 — it can't join a shared netns — so this uses the host-gateway variant (sidecar in its own netns proxying host :81 via host.docker.internal). NPM's :80/:443 proxying is untouched. Also records the trusted-HTTPS-edge plan against the real domain ulises-c.me (*.home.ulises-c.me wildcard via DNS-01 + AdGuard split-horizon rewrite). Domain is owned so the prereq is met; documented in HTTPS.md/TODO to set up later, no NPM/AdGuard changes made now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied and verified on ollie-server: admin UI sidecar live, NPM itself untouched (still binds host :80/:443/:81 as the non-tailnet edge), homepage link updated. Every entry in the rollout table is now done. Also fixed a stale comment in homepage/.env.example left over from the tailscale-web port fix (said :5252, should say :8088). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… monitoring Watchtower is headless (no UI), so to track up/down like the other services it gets a sidecar fronting only its token-gated HTTP metrics API: WATCHTOWER_HTTP_API_METRICS=true + WATCHTOWER_HTTP_API_TOKEN, served at https://watchtower.<tailnet>.ts.net/v1/metrics. Metrics-only — NOT the update API — so periodic polls (the 3am WATCHTOWER_SCHEDULE) keep running. Uptime Kuma monitors it with an HTTP check + 'Authorization: Bearer <token>' header (recipe in HTTPS.md). No homepage href — the endpoint is an API, not a UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deployed the watchtower-ts sidecar; confirmed 401 unauthenticated / 200 with the Bearer token on /v1/metrics, and that the 3am update schedule is unaffected. Uptime Kuma monitor still needs wiring up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the post-HTTPS-rollout improvements. Lead item is watchtower observability — its /v1/metrics is counters-only (no names/versions), so 'what updated and when' needs notifications/logs: Tier 1 ntfy notifications (reuse the existing ntfy), Tier 2 Prometheus+Grafana on the metrics endpoint, Tier 3 a dedicated update-tracker UI (WUD/Diun). Plus the broader list: pin the sidecar image, backups, DRY the sidecar boilerplate, shared TS_AUTHKEY, a server-stacks validation script, ACL tightening, and forward-auth for the NPM public edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ulises-c
marked this pull request as ready for review
June 17, 2026 12:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the Ubuntu Server install a profile of the unified Linux path instead of a parallel reimplementation. Previously
platforms/server.shhad its ownplatform_main()that duplicated the entire install spine (install tiers, Tailscale, Docker, claude-code, dotfile deploys); now it reuses the shared engine and layers the server-only infra on top as an explicit "step two."Motivation: streamline the server install and cut divergence so the server "follows most of the main install, then does server-specific stuff."
What changed
lib/core.shSERVER_PROFILEbool, set incore_detect_platformwhen the platform isserver.desktop_main→linux_main: one shared spine (bootstrap → install tiers → Tailscale → claude-code → shell → dotfiles). Desktop-only steps (pyenv/nvm/rust/pipx/pnpm/p10k/ghostty) are gated off underSERVER_PROFILE, which instead installs the Docker engine, runsserver_extras, and printsserver_footer.apt_install_tier, the curl-basedplatform_tailscale_stepandplatform_docker_optional) here as defaults, beside the existingapt_bootstrap/snap_install_tier.arch.shoverrides them with its pacman versions (sourced last, so it wins).platforms/server.sh(222 → 188 lines) — now only genuine server quirks: the tier composition (low = apt only, no snap),server_preclone_antidote, theserver_extrasstep-two (cockpit, Tailscale web service, AdGuard port 53,.env/APP_KEY generation, docker-compose stacks), andserver_footer.platform_mainis justlinux_main.platforms/ubuntu.sh/arch.sh— drop the now-shared hook bodies and calllinux_main.The headless
zshrc/zsh_pluginsoverrides are unchanged —deploy_zshrcstill prefersCONFIG_SRC_DIR.Validation
shellcheck --severity=warningclean on all touched files.scripts/dryrun-smoke.shpasses all four platforms (macos 79, ubuntu 33, arch 19, server 33 actions).Notes / follow-ups
forgejostack is still absent from the compose loop inserver_extras(preserved from the original — not started today even thoughlinux-server/forgejo/docker-compose.ymlexists). Flagging in case it should be added.Ctrl-b ctmux issue: the server has never hadsetup.shrun live, so its~/.tmux.confis likely stale. Runningbash setup.sh --profile serverwill deploy the repo'stmux.conf(backing up the old one).🤖 Generated with Claude Code