Other services
The smaller self-hosted services, one section each. BookOrbit and the BookLore it replaced, Homarr, Samba, Ollama and Open WebUI on the GTX 1080 Ti, Sure, and PeaNUT over NUT.
These are the services that don’t need a page of their own. Most run on docker-main (LXC 110 on grey-server) as their own Compose project under /opt/docker/<name>/, follow the Compose conventions, and are reached over internal HTTPS through Nginx Proxy Manager with the direct address kept as a fallback. None of them is published to the internet.
| Service | Purpose | Host | Version |
|---|---|---|---|
| BookOrbit | Ebook library | docker-main | 3.1.0, replaced BookLore on 2026-09-28 |
| Homarr | Dashboard | docker-main | 1.77.2 |
| Samba | File server for my own machines | ubuntu-dev | Not recorded |
| Ollama | Local LLM API on the GPU | docker-main | 0.33.3 |
| Open WebUI | Browser frontend for Ollama | docker-main | 0.11.4 on 2026-09-24 |
| Sure | Personal finance tracker | docker-main | 0.7.5-hotfix.1 |
| PeaNUT and NUT | UPS telemetry and its dashboard | monitor-01, grey-server, red-server | PeaNUT 6.0.0, NUT 2.8.1-5 |
BookLore and BookOrbit
BookLore was my ebook library on docker-main: v2.4.0 with MariaDB 11.4.8 pinned to the application-supported tag. On 2026-09-28 I replaced it with BookOrbit and retired it.
BookOrbit v3.1.0 runs from /opt/docker/bookorbit with PostgreSQL 18.6 and pgvector. The application image is pinned by digest to the running v3.1.0. The library is the same 42 books at their original path, mounted read-write at /books. Library watching, automatic file metadata writes and file renaming are all disabled, and the optional text-to-speech profile is off.
I followed BookOrbit’s official migration workflow:
- Deploy alongside. I brought BookOrbit up on a separate port with the original books and covers mounted read-only, attached to BookLore’s database network, while BookLore kept serving. The first start failed because the container’s user couldn’t write its new data directory; I fixed ownership on that directory only.
- Scan and dry run. The scan added all 42 books with none missing. The dry run matched 42 books, with zero unresolved and zero duplicate matches, and mapped the one account.
- Import. The import finished with zero failed entities: 42 books’ metadata, 42 author links, 131 genre links, 41 covers, and four rows each of reading progress, sessions and statuses, plus one annotation. The one missing cover had no image directory in the source.
- Cut over. I stopped BookLore, ran a second import against its still-running database (it matched all 42 again, with no failures and no duplicated records), moved BookOrbit to BookLore’s old port and HTTPS name, and made the library writable. The proxy host, DNS record, certificate and firewall rule stayed exactly as they were.
- Verify and retire. A fresh login passed, authenticated range reads succeeded for all 42 book files through HTTPS, and both containers were healthy. Then I removed the BookLore and MariaDB containers and their network, moved the old project to
/opt/retired/booklore, swapped the stack in Dockhand, and renamed the Homarr shortcut.
The original MariaDB data and BookLore application data stay on disk, because they hold a few settings the importer doesn’t transfer: two ebook viewer preferences and one Kobo settings record.
Homarr
Homarr 1.77.2 is my dashboard, deployed on 2026-09-27 at the internal name the retired Homelab Dashboard used. It is the single-container layout from Homarr’s official instructions, with a pinned release tag, a port bound to the host’s own address, bounded logs, and an encryption key loaded from a mode-0600 .env. Its embedded SQLite and Redis keep their state under /opt/docker/homarr/appdata. I didn’t mount the Docker socket.
Warning: The
.envencryption key and theappdatadirectory belong together. Replacing the key breaks access to every integration secret Homarr has stored.
I use one private dashboard as my desktop and mobile home page. It holds nine integrations, nine data widgets and 32 website shortcuts with status checks:
| Integration | What it shows |
|---|---|
| Sonarr and Radarr | The release calendar |
| Prowlarr | Indexer status |
| qBittorrent | Download status |
| Seerr | The request list |
| Jellyfin | Active streams and recently added media |
| Proxmox | Cluster health, through a dedicated API token with the read-only PVEAuditor role |
| PeaNUT | UPS-02 battery, load and voltage |
| Gluetun | VPN status, through a key whose role allows only four GET routes |
Analytics and search-engine indexing are disabled. Status checks measure HTTP reachability from Homarr, not a successful login; in the first check, 27 of the 32 shortcuts returned HTTP 200, and Windows Admin Center and four Proxmox node interfaces timed out. I upgrade it by reading the release notes, changing the pinned tag, pulling, and repeating the HTTPS and login checks. The image has no Docker health check, so readiness and an authenticated request are the checks.
The Proxmox integration first failed hostname validation against the node’s address. The node presents a certificate for its DNS name, so I used the name, removed a CA I had briefly imported, and left no TLS exception in place.
Samba
Samba runs on ubuntu-dev, my development VM, as a file server for my own machines. The versioned smb.conf in the repository is the live file.
| Setting | Value |
|---|---|
| Protocols | SMB2 (SMB2_02) through SMB3 only; NetBIOS off |
| Authentication | One Samba login; guest access off (map to guest = never) |
| File ownership | Every file operation runs as one local account through force user |
| Permissions | New files 0600 with no group or other bits, directories 0700 |
| macOS support | The catia, fruit and streams_xattr modules keep macOS metadata and filenames intact |
| Client restriction | None in Samba; the UniFi firewall decides who reaches the SMB port |
There are two read-write shares: a home share, and shared-folder, added on 2026-09-14. I validated the change with testparm -s, reloaded with smbcontrol all reload-config, and ran an authenticated upload, download, byte comparison and delete on the host. No machine has a persistent mount of it yet.
Ollama and Open WebUI
Ollama runs on docker-main with the GTX 1080 Ti from grey-server, and Open WebUI shares its Compose project at /opt/docker/ollama.
| Item | Value |
|---|---|
| Ollama | ollama/ollama:0.33.3, pinned by digest |
| GPU | NVIDIA GeForce GTX 1080 Ti, 11,264 MiB, compute capability 6.1 |
| Driver | Proprietary 580.159.03 on grey-server, with matching user space inside the LXC |
| Container runtime | NVIDIA Container Toolkit 1.20.0-1; Compose sets runtime: nvidia and requests all GPUs |
| Model | qwen3.5:2b: 2.3B parameters, Q8_0, 2.7 GB on disk |
| Models storage | Named volume ollama_ollama-data |
| Open WebUI | Rolling main tag with pull_policy: always, 0.11.4 on 2026-09-24, named volume ollama_open-webui-data |
The GPU path
grey-server owns the NVIDIA kernel modules. On 2026-09-04 I rebuilt the retained, patched 580.159.03 DKMS source for the running Proxmox kernel and the next installed one, installed the matching headers, and passed the card’s seven real character devices into the unprivileged LXC, leaving out stale vGPU nodes. Inside the LXC I installed only the user-space files from NVIDIA’s official runfile, after checking its SHA-256 against NVIDIA’s published checksum. A host service, nvidia-lxc-devices.service, loads the modules and creates the device nodes, and is ordered before pve-guests.service so the LXC never starts before its devices exist.
The card is Pascal. It stays on the proprietary R580 branch, because the open kernel modules and R590 or newer don’t support it, and before any Proxmox kernel reboot I confirm the target kernel already has a 580.159.03 DKMS build.
The first docker compose up left Ollama in Created:
nvidia-container-cli: mount error: failed to add device rules: unable to find any existing
device filters attached to the cgroup: bpf_prog_query(BPF_CGROUP_DEVICE) failed: operation not permitted
The ordinary device request can’t inspect the outer LXC’s device filter, while the explicitly registered NVIDIA runtime passes the same workload. Adding runtime: nvidia beside gpus: all fixed it without turning off cgroup enforcement, and I haven’t enabled the toolkit’s no-cgroups option.
Models
I deployed Llama 3.1 8B first. All 33 of 33 layers offloaded to the card and NVIDIA reported 5,187 MiB in use. Later the same day I replaced it with qwen3.5:2b for lower-latency post-processing of Handy speech-to-text transcripts through Ollama’s OpenAI-compatible API, testing Qwen before deleting Llama. Loaded with a 4,096-token context, Qwen runs at 100% GPU and the card shows about 3.1 GiB in use. Handy’s long default cleanup prompt didn’t work reliably with a 2B model, so I use a shorter tested prompt instead.
Exposure
The Ollama API has no built-in authentication, so it is not behind the reverse proxy and has no WAN port forward. It listens only on the host’s own address, and I call it only from trusted internal clients.
Open WebUI is the authenticated frontend. The first account registered becomes the administrator and closes sign-up. It reaches Ollama by its Compose service name over the private Compose network and gets no GPU access itself; Ollama is the only component that runs inference. Its signing key is generated locally into the project’s mode-0600 .env. It is published only over internal HTTPS through Nginx Proxy Manager, and the public resolver returns NXDOMAIN for its name.
I first pinned Open WebUI to a v0.11.3 digest, then deliberately switched it to the rolling main tag, trading reproducible deploys for automatic tracking on each pull. Ollama stays pinned, and updating it means checking the release, changing the digest in both the repository and the live Compose file, and repeating the full generation test.
Sure
Sure is my personal finance tracker, the community fork of Maybe Finance, deployed on 2026-10-05 from its official Docker guide.
| Item | Value |
|---|---|
| Images | ghcr.io/we-promise/sure:0.7.5-hotfix.1 for web and worker, postgres:16, redis:8 |
| Containers | sure-web, sure-worker (Sidekiq), sure-db, sure-redis |
| Project | /opt/docker/sure, project name sure |
| Data | Bind mounts under /opt/docker/sure/data/ for uploads, PostgreSQL and Redis |
| Secrets | SECRET_KEY_BASE, the PostgreSQL password and three Active Record encryption keys, generated on the host straight into a root-owned mode-0600 .env |
| Accounts | One administrator from my standard application account; public sign-up closed |
My changes from the official example:
| Change | Reason |
|---|---|
Pinned 0.7.5-hotfix.1 and redis:8 instead of floating tags | Upgrades happen when I choose them. A newer hotfix had a GitHub release that morning but no image on GHCR yet |
RAILS_ASSUME_SSL and APP_DOMAIN set | The reverse proxy terminates TLS |
| Market-data providers set to Yahoo Finance | The example .env sets them, but the example Compose file never passes them to the containers, so Sure would default to a provider that needs an API key |
Secrets required with ${VAR:?} | The example falls back to a published SECRET_KEY_BASE and database password when .env is missing |
| Bind mounts instead of named volumes, bounded logs, explicit container names | Matches the other projects on this host |
Encryption keys
On first boot Sure logged that Active Record encryption was not configured. I read the initializer and concluded the models were encrypting with keys derived from SECRET_KEY_BASE. That was wrong. Sure has two separate checks: Rails held derived keys, but the models only encrypt when the keys are explicitly configured, so nothing had been encrypted.
Later that day I derived the same three values from SECRET_KEY_BASE, exactly as the initializer does, wrote them explicitly into .env, and confirmed their fingerprints matched the runtime keys without printing them. Login then failed with HTTP 422, because the email column still held plaintext and Sure now looked users up by the encrypted form. Sure’s documented backfill path fixed it: a temporary override allowing unencrypted reads, a dry run of security:backfill_encryption (1 user, 4 sessions, 0 failures), the real run, and removal of the override. The email and name columns now hold ciphertext and every encrypted attribute decrypts.
Warning: The three encryption keys must never change. Sure encrypts emails, names, sessions and provider tokens with them, so a new value makes all of that unreadable and breaks login. The
.envfile and the PostgreSQL data belong together.
Open items: Yahoo Finance returns HTTP 429 to my address, so exchange rates and prices fail until I add a Twelve Data key, and there is no SMTP, so password reset by email doesn’t work.
PeaNUT and NUT
The lab has two APC Back-UPS Pro BR1500MS2 units. Network UPS Tools owns each unit’s USB connection on the Proxmox node it is plugged into, and PeaNUT is the browser view over the NUT servers.
| Component | Where | Role |
|---|---|---|
NUT ups02 | grey-server | Reads UPS-02 over USB and publishes telemetry. UPS-02 feeds every Galaxy node |
NUT ups01 | red-server | Disabled since 2026-08-31 |
| PeaNUT 6.0.0 | monitor-01 | Dashboard over the NUT endpoints, behind a login |
| NUT exporter | monitor-01 | Feeds the same UPS data to Prometheus |
I deployed NUT 2.8.1-5 on both nodes on 2026-07-22. NUT here is telemetry only: nut-monitor.service is disabled on both nodes, so nothing in this deployment shuts a Proxmox host down. The Proxmox cluster firewall admits only monitor-01 to the NUT port on the nodes, and the versioned configuration holds no password, UPS serial number or command-capable NUT account.
PeaNUT first ran on docker-main. On 2026-07-26 I rebuilt it on monitor-01 so the dashboard and the Prometheus NUT exporter read the same endpoints from one host. It was a clean rebuild, because PeaNUT’s whole persistent state is a 554-byte settings.yml. I reused the stored login and session secret so nobody was signed out, and wrote the new .env with LF line endings after confirming that the running container had received the old CRLF file’s values without stray carriage returns. I cut the proxy over before tearing down the old container, so the HTTPS name never returned an error, and a settings change that survived a restart proved the container could write its own configuration.
UPS-01 has been unmonitored since its USB cable came off red-server on 2026-08-28, when I moved that node onto UPS-02. The NUT driver for it restarted every five seconds for three and a half days, reaching 12,014 restarts and writing 75,292 journal lines in a day, until I disabled it on 2026-08-31. On 2026-09-07 I decided UPS-01 stays unmonitored: it still carries its loads, and UPS-02, which powers the cluster, still reports normally. PeaNUT keeps the UPS-01 entry marked disabled.
Note: The versioned
settings.ymland thered-serverups.confpredate the 2026-08-31 change and still enable UPS-01. Rebuilding from them as they stand would restart the driver loop, so the two changes from the troubleshooting record go in first.