Wiki

homelab setup notes & runbooks
22 topics
Infrastructure

# Proxmox 192.168.10.20

Type-1 hypervisor (Proxmox VE) running the home lab. The as2.no host (Server4-Ubuntu2) is itself VM 104 on this hypervisor. Administered from as2.no over SSH as root (key auth).

VM memory reads high in Proxmox — that's cache, not a leak. Proxmox's per-VM memory gauge counts everything the guest has touched (incl. Linux page cache), so it shows ~85–90% for a VM that's actually using far less. The real figure is on the Linux Server page (from inside the guest, cache excluded) — e.g. VM 104 shows ~87% in Proxmox but ~41% real. The qemu-guest-agent is installed (fixes graceful shutdown, fs-freeze backups, guest-IP reporting) but on PVE 9.x it does not change this memory gauge. So: trust the guest/Linux-Server number for real usage, Proxmox's for reserved/touched.

Version
PVE 9.1.4
kernel 6.17.4-2-pve
Hardware
i7-9700
8C/8T @ 3.0 GHz · 64 GiB RAM
System disk
960 EVO
Samsung 1 TB NVMe
Location
Bod
cabled to C2960CX-3 (192.168.10.2) · wireless backhaul
NodeServer4-Proxmox
Installed2024-05-09 (originally PVE 8.2)
LAN IP192.168.10.20
Web UIhttps://proxmox.as2.no 🔒 (valid LE cert; also 192.168.10.20:8006)
Networkvmbr0 bridge → enp3s0, gateway 192.168.10.1
Admin accessSSH key (root) from as2.no

Virtual machines (qm list):

VMIDNameRAMDiskState
101Server4-Win10 GB100 GB running
102Server4-Win11-test4 GB64 GB running
104Server4-Ubuntu24 GB40 GB running (as2.no)
105CML8 GB50 GB stopped
106eve-ng16 GB40 GB stopped

Storage (pvesm status):

StorageTypeSizeUsedPurpose
localdir96 GB25%ISOs, templates, host backups
local-lvmlvmthin832 GB20%VM disk images (960 EVO NVMe)
Server4-Win-backupcifs7.8 TB29%SMB share for VM backups

All seven passthrough drives report SMART overall-health PASSED (live on the Proxmox page) — but that threshold-based verdict hides two latent issues (last deep-checked 2026-06-21):

  • sdb (Z8401V6Z, 8 TB ST8000AS0002) carries latent damage — a short self-test ends in a read failure at LBA 273408, with 16 pending / 16 offline-uncorrectable sectors and 21 UDMA CRC errors. SMART still says PASSED because pending sectors don't trip the threshold; treat it as failing. It only holds the backup copy of H: (healthy), so no primary data is at risk — replace when convenient.
  • Both ST8000DM004 drives (sdd/sde) have ~8.5 years (74,100+ h) of runtime — plan replacements.
  • Passthrough disks are excluded from Proxmox backups (backup=0).

Backups & offsite (Jottacloud). VM images are dumped with vzdump (snapshot mode, zstd) to the Server4-Win-backup CIFS share — //192.168.10.3/Proxmox-backup on the Windows fileserver — then Jottacloud on that box uploads them offsite nightly at 02:00. Run manually for now (no scheduled job). Consistency: 101/104 get a guest-agent fs-freeze (101 = Windows/VSS-consistent, 104 = fs-consistent); 105/106 are consistent while stopped; 102 now has the guest agent too (added 2026-07-07), so its backups are fs-consistent.

⚠️ Run the Proxmox backup before 02:00. It must finish the .vma.dat → .vma.zst rename before Jottacloud starts — otherwise Jottacloud opens the freshly-written .dat to upload it and Proxmox's rename fails with a sharing violation (unable to rename … .vma.dat to … .vma.zst). This silently broke every backup from ~mid-2025 until 2026-07-07; the fix is scheduling Jottacloud (not real-time) and keeping the two from overlapping.

# Re-check one drive's health + attributes
ssh root@192.168.10.20 smartctl -H -A /dev/sdb

# Manual VM backup -> Windows share (run BEFORE 02:00 / Jottacloud)
ssh root@192.168.10.20 vzdump 104 --storage Server4-Win-backup --mode snapshot --compress zstd

# Manage VMs (list / start / stop / config)
ssh root@192.168.10.20 qm list
ssh root@192.168.10.20 qm start 106

# Server4-Win VM 101 · 192.168.10.3

The Windows 11 file server. Six physical drives are passed straight through from the host (raw NVMe/SATA passthrough), so Windows owns and formats them directly. The QEMU guest agent is enabled, so the disks can be queried remotely from as2.no. Files are shared over FTPS via FileZilla Server (explicit TLS required, min TLS 1.2).

OSWindows 11
vCPU / RAM4 cores · 10 GiB
FirmwareUEFI (OVMF), q35
System disk100 GB on local-lvm → C:
File sharingFileZilla Server — explicit FTPS on port 21212 (PASV 50000–50100)
Guest agentenabled

Physical disk → Windows drive-letter map:

DriveSizeLetterHost devNotes
WD Blue SN570 NVMe2 TBJ:nvme0n1
Samsung 860 EVO1 TBF:sda
Seagate ST8000AS0002 (Z8401TPQ)8 TBH:sdchealthy — primary
Seagate ST8000AS0002 (Z8401V6Z)8 TBI:sdbFAILING — backup of H:
Seagate ST8000DM004 (WCT03T91)8 TBE:sdeaging — primary
Seagate ST8000DM004 (WCT04458)8 TBD:sddaging — backup of E:
OS disk (local-lvm)100 GBC:Windows system
Seagate Expansion (USB)8 TBG:external, not passthrough
  • Backup pairs: I: backs up H:, and D: backs up E:.
  • I: (failing) only holds the backup of H: (healthy) — no primary data at risk, but the H: → I: backup is unreliable until replaced.
  • G: is a separate external USB drive (Seagate Expansion).

# Server4-Win11-test VM 102 · 192.168.10.14

Scratch Windows 11 VM for testing.

OSWindows 11
vCPU / RAM2 cores · 4 GiB
FirmwareUEFI (OVMF), q35
Disks64 GB system + 32 GB scratch (local-lvm)
Staterunning

# Server4-Ubuntu2 VM 104 · 192.168.10.15

The Ubuntu host serving as2.noVM 104 on the Proxmox box. Live metrics on the Linux page.

HostVM 104 on Server4-Proxmox
OSUbuntu 24.04.4 LTS
CPU / RAM / Disk4 vCPUs @ ~2 GHz · 3.8 GiB · 40 GiB
Local IP192.168.10.15
Public IP85.165.99.110

# CML VM 105 · 192.168.10.16

Cisco Modeling Labs (CML 2.10.0) — Cisco's official network simulation platform. Runs virtual topologies of real Cisco node types (IOS, IOS-XE, IOS-XR, NX-OS) for building and testing network designs — certification study (CCNA / CCNP / CCIE) and pre-deployment validation. Normally powered off; start it when needed.

Upgraded 2.8 → 2.10.0 on 2026-07-08. Gotcha for next time: CML 2.10+ pulls docker-ce from download.docker.com (via the new cml-docker-shim package), but the repo line in /etc/apt/sources.list.d/docker.list ships commented out. Result: the Cockpit upgrade reports SUCCESS (code 0) but silently stays on the old version, because docker-ce is "not installable" so the whole cml2 upgrade is held. Fix: uncomment the deb …download.docker.com… noble stable line, sudo apt-get update (confirm apt-cache policy docker-ce shows a candidate), then re-run the Cockpit upgrade and reboot. Two attempts failed silently before we found this.

OSLinux (CML 2.8.0)
vCPU / RAM4 cores · 8 GiB
Disk50 GB (local-lvm)
Statestopped

# eve-ng VM 106 · 192.168.10.18

EVE-NG Community Edition (6.2.0) — a multi-vendor network emulation platform. Runs virtual devices from many vendors (Cisco, Juniper, Arista, Fortinet and more) in a single topology, complementing CML for multi-vendor labs and certification practice. Normally powered off; start it when needed.

OSLinux (EVE-NG CE 6.2.0)
vCPU / RAM4 cores · 16 GiB
Disk40 GB (local-lvm)
Statestopped

# Jellyfin CT 111 · 192.168.10.11

Self-hosted media server for movies & TV, in a privileged LXC on Proxmox. Hardware transcoding runs on the host's Intel iGPU (QuickSync); the media library is mounted read-only over CIFS from Server4-Win. Reachable at https://jellyfin.as2.no (valid cert; nginx → container :8096) — on the LAN, over WireGuard, and publicly on the internet (family access, no VPN) since 2026-07-31.

OSDebian 12 (LXC, privileged)
vCPU / RAM4 cores · 4 GiB
Disk16 GB (local-lvm) — OS + metadata; media stays on the share
Web UIjellyfin.as2.no · 192.168.10.11:8096
TranscodingIntel QuickSync (UHD 630) via VAAPI + HDR tone-map
MediaMovies + Series, read-only CIFS from 192.168.10.3
  • QuickSync passthrough: /dev/dri bound into the CT (cgroup2 device allows in /etc/pve/lxc/111.conf); the jellyfin user is in render+video. intel-opencl-icd installed for HDR tone-mapping.
  • Media mounts: host CIFS mounts (ro, creds root-only) bind-mounted into the CT at /media/Movies and /media/Series.
  • Reverse proxy: nginx vhost on 192.168.10.15192.168.10.11:8096 (websockets + proxy_buffering off); on the shared as2.no Let's Encrypt cert (see HTTPS). Jellyfin KnownProxies = .15.
  • Public exposure: ASUS router forwards TCP 443 → .15; hardened at nginx with a login rate-limit (60r/m on /Users/AuthenticateByName), a fail2ban jail (5 fails / 10 min → 1 h ban; LAN/WG/docker whitelisted), and HSTS/security headers.
  • Up/down + version probed by the summary poller (see Host pollers) and shown on the Services page.
# Manage from the Proxmox host (192.168.10.20)
pct exec 111 -- systemctl status jellyfin
pct exec 111 -- systemctl restart jellyfin

# Container console
pct enter 111
Networking

# Networking

Flat single-VLAN L2 on 192.168.10.0/24 — one subnet, no inter-VLAN routing. Full address map on the IP Plan page.

  • Gateway / WAN: ASUS router on 192.168.10.1 — see Router.
  • Switching: 4× Cisco Catalyst 2960CX — see Switches.
  • Wireless: 4× Cisco AP4800 + embedded WLC — see Wireless.
  • Firewall: UFW — ports 80/443 restricted to 192.168.10.0/24 (local only).
  • DNS: Domeneshop — as2.no and arildsorli.no A records point to 85.165.99.110.
  • HTTPS / TLS: Let's Encrypt (DNS-01) — see the HTTPS section.
  • LAN DNS resolver: dnsmasq on 192.168.10.15 — see dnsmasq.

# Switches — Cisco Catalyst 2960CX 4 × WS-C2960CX-8PC-L

Uniform four-switch access layer, one per room. SSH key auth on all four; IOS version, health (CPU / mem / flash / temp / uptime) and up/down status are polled live (SNMP v2c) and shown on the Topology page. All four currently up on IOS 15.2(7)E14 (fleet-upgraded from 15.2(7)E8).

HostnameIPLocationIOSRole
C2960CX-1192.168.10.4Stue15.2(7)E14feeds WLC root AP
C2960CX-2192.168.10.5Kjellerstue15.2(7)E14
C2960CX-3192.168.10.2Bod15.2(7)E14Proxmox uplink
C2960CX-4192.168.10.6Kontor15.2(7)E14via AP4800-4 wireless backhaul
  • Model WS-C2960CX-8PC-L (8-port PoE) across the fleet.
  • Monitored via SNMP v2c; each switch forwards syslog to 192.168.10.15:514 → Loki (see Syslog).

# Wireless — Cisco AP 4800 + WLC AireOS 8.10.196.0

Four Cisco Aironet AP4800s, one per room, joined to an embedded WLC (Mobility Express) running on AP4800-1. AP4800-1 (Stue) is the wireless root and backhauls to each room's AP; the controller is reachable at the VIP 192.168.10.7. Live WiFi client counts per radio are on the WiFi page.

APMgmt IPLocationMACNotes
AP4800-1192.168.10.120Stue78:72:5d:fb:8f:eaWLC ME controller — VIP 192.168.10.7
AP4800-2192.168.10.152Kjellerstue78:72:5d:3f:9a:c2
AP4800-3192.168.10.132Bod78:72:5d:fb:8f:f6
AP4800-4192.168.10.105Kontor78:72:5d:3f:89:2cwireless backhaul for C2960CX-4
  • Embedded WLC (Mobility Express), AireOS 8.10.196.0 — version read live from the poller.
  • Controller VIP 192.168.10.7; the WLC also forwards syslog under host label AP4800-1.

# Router — ASUS 192.168.10.1

The ASUS router is the LAN gateway and WAN edge. It serves DHCP, holds the public IP (dynamic, kept current by the Domeneshop updater), and forwards only WireGuard 51820/udp inbound. SSH-polled every 30s for per-interface throughput and WiFi-client counts.

RoleLAN gateway · default route
LAN IP192.168.10.1
Public IP85.165.99.110 (dynamic, DDNS)
DHCP pool192.168.10.100 – 250
InboundWireGuard 51820/udp only
MonitoringSSH poller, 30s (rx/tx per interface, WiFi clients per radio)

# dnsmasq LAN DNS

Local DNS resolver for the home network — forwards queries upstream and supports custom A records.

DNS port53/tcp+udp (LAN only)
Upstream208.67.222.222, 208.67.220.220 (OpenDNS)
Config~/dnsmasq/dnsmasq.conf
Bind address192.168.10.15
  • Point the router's DNS server to 192.168.10.15 to use it for all LAN clients.
  • Add custom hostnames with address=/hostname.local/192.168.x.x.
  • *.as2.no resolves to 192.168.10.15 on the LAN.
  • UFW allows port 53 from 192.168.10.0/24 only.
# Start / restart
cd ~/dnsmasq
docker compose up -d

# View logs
docker logs -f dnsmasq

# Test a query from the LAN
dig @192.168.10.15 google.com

# WireGuard VPN

Self-hosted VPN via wg-easy — a Docker image that bundles WireGuard with a web UI for managing clients.

Hostas2.no
VPN port51820/udp
Web UI port51821/tcp
DNS1.1.1.1 (Cloudflare)
Config~/wg-easy/docker-compose.yml
  • WireGuard config stored in Docker volume etc_wireguard.
  • Web UI at https://wg.as2.no 🔒 (valid cert; nginx → container :51821) — manage clients, download configs.
  • Requires kernel capabilities NET_ADMIN and SYS_MODULE.
  • IP forwarding enabled via net.ipv4.ip_forward=1.
# Start / restart
cd ~/wg-easy
docker compose up -d

# View logs
docker logs -f wg-easy

# HTTPS — Let's Encrypt DNS-01

TLS certificates issued by Let's Encrypt using the DNS-01 challenge — no public port 80 required.

Plugincertbot-dns-domeneshop
Domainas2.no (+ *.as2.no wildcard)
Cert path/etc/letsencrypt/live/as2.no/
RenewalAuto via certbot.timer (apt certbot, 2×/day)
  • DNS-01 works by creating a temporary _acme-challenge TXT record at Domeneshop.
  • Plugin credentials stored in /etc/letsencrypt/renewal/as2.no.conf.
  • Nginx uses fullchain.pem and privkey.pem from the cert path.
# Check certificate expiry
sudo certbot certificates

# Force renewal (for testing)
sudo certbot renew --dry-run

# Check renewal timer
systemctl status certbot.timer

# Domeneshop IP Updater

Monitors the server's public IP and automatically updates the as2.no A record at Domeneshop when it changes.

  • Domains: as2.no, arildsorli.no.
  • Script: ~/domeneshop-ip-updater/updater.py.
  • Credentials: /etc/domeneshop-ip-updater/config (chmod 600).
  • State file: /var/lib/domeneshop-ip-updater/last_ip.
  • Runs every 5 minutes via domeneshop-ip-updater.timer.
  • GitHub: github.com/arildsorli/domeneshop-ip-updater (private).
# Check logs
journalctl -u domeneshop-ip-updater.service -f

# Check timer status
systemctl status domeneshop-ip-updater.timer
Observability

# Monitoring stack Grafana · InfluxDB · Loki

Two pipelines, both Docker Compose at /home/arild/monitoring on 192.168.10.15 (its own git repo). Metrics: Telegraf → InfluxDB v2 → Grafana. Logs: devices → AxoSyslog (514) → Loki → Grafana. Embedded dashboards on the Monitoring page; the v2 overview cards come from the host pollers.

ContainerImagePortRole
grafanagrafana/grafana:11.3.0:3000dashboards (nginx → grafana.as2.no)
influxdbinfluxdb:2.7127.0.0.1:8086metrics TSDB — bucket network, org as2
telegraftelegraf:1.32SNMP poller (switches + WLC) + host metrics
lokigrafana/loki:3.2.1127.0.0.1:3100log store — filesystem, 30-day
axosyslogaxoflow/axosyslog:4.8.1:514 udp+tcpsyslog receiver → Loki (see Syslog)

What Telegraf collects (every 30s): SNMP v2c from the four 2960CX switches and the AireOS WLC at 192.168.10.7 — interface counters/errors, CPU, memory, flash, temperature, uptime, plus each switch's IOS sysDescr and the WLC's swVersion (these drive the live version badges on Topology) and per-AP client counts. It also reads this server's own CPU / memory / disk / NIC via a read-only /hostfs mount.

  • Grafana access: nginx vhost grafana.as2.no127.0.0.1:3000 (also :3000 direct on the LAN), on the as2.no Let's Encrypt cert. Anonymous Viewer + embedding are enabled so the dashboard iframes render; admin password is set in the UI (.env holds only the bootstrap value).
  • InfluxDB data model: measurements asus_netdev (WAN/wired/wireless rx/tx), snmp_interface (switch + WLC interface counters/errors), switch_cpu / switch_mem / switch_env / switch_flash, wlc_ap_clients, and host cpu/mem/disk/net. Throughput = non-negative derivative × 8 (bits/s).
  • Loki: single-binary, filesystem store, 30-day retention; labels host · program · severity · facility.
  • Secrets (InfluxDB token, Grafana pw, SNMP communities) live in ~/monitoring/.env (chmod 600, never committed).

# Syslog pipeline AxoSyslog → Loki

Switches, the WLC and the ASUS router forward syslog to 192.168.10.15:514. AxoSyslog normalises every format (including Cisco's non-standard frame), maps source IPs to friendly host names and ships to Loki — searchable in Grafana and on the Syslog page by host, severity, program and facility.

ReceiverAxoSyslog on 192.168.10.15:514 (udp/tcp)
StoreLoki — 30-day retention, 127.0.0.1:3100
Labelshost · severity · program · facility
Sourcesrouter (192.168.10.1), C2960CX-1…4, WLC/AP (192.168.10.7 → label AP4800-1)

# Host pollers systemd timers

Small scripts on 192.168.10.15 (in ~/monitoring/scripts, run by systemd timers) that feed data the SNMP path can't. Two write straight to InfluxDB; two emit the JSON this v2 dashboard reads — which is why the cards refresh roughly every minute.

TimerEveryScriptOutput
asus-wan-poll30sasus_wan_poll.shWAN / per-iface rx/tx → InfluxDB asus_netdev
asus-wifi-poll30sasus_wifi_poll.shWiFi clients per radio → InfluxDB
client-inventory5 minclient_inventory.pydata/clients.json — merged roster (router · DHCP · ARP · Cisco MAC)
as2-summary60sas2_summary.pydata/summary.json — overview cards
prune-code-extensionsdailyprune-code-extensions.shkeeps newest version of each code-server extension (they never self-prune — Claude Code had grown to ~7 GB)
update-ieee-datamonthlyupdate-ieee-data -frefreshes the IEEE OUI database so client_inventory resolves vendors from MAC prefixes
  • as2_summary.py assembles the overview: WAN throughput, switches + parsed IOS, WLC AireOS version, WiFi totals, Proxmox VM count + SMART disk health, server CPU/mem/disk, syslog rate.
  • The ASUS pollers SSH in with a dedicated Dropbear key (~/.ssh/asus_poller_ed25519), independent of the router's single web-admin session.
  • Version-controlled unit files live in ~/monitoring/systemd/; the live copies are in /etc/systemd/system/.
# Poller status / logs
systemctl status as2-summary.timer
journalctl -u as2-summary.service -f

# Run a poller once by hand
~/monitoring/scripts/as2_summary.py

# Device-side SNMP/syslog config switches · WLC · router

What was applied on each device so it reports to the stack on 192.168.10.15. SNMP is read-only and locked to 192.168.10.15; syslog is sent to 192.168.10.15:514. Community strings live in ~/monitoring/.env (shown as placeholders below).

Cisco 2960CX switches (cx-1…4) — in config mode, saved to startup-config:

access-list 99 permit 192.168.10.15
snmp-server community <SNMP_COMMUNITY> RO 99
logging host 192.168.10.15
logging trap informational
! end & write memory

WLC (192.168.10.7, AireOS Mobility Express) — SNMP RO community locked to 192.168.10.15 + syslog at notifications level. Applied once via ~/monitoring/scripts/wlc_phase2_config.exp <community>:

config snmp community create <WLC_SNMP_COMMUNITY>
config snmp community accessmode ro <WLC_SNMP_COMMUNITY>
config snmp community ipaddr 192.168.10.15 255.255.255.255 <WLC_SNMP_COMMUNITY>
config snmp community mode enable <WLC_SNMP_COMMUNITY>
config snmp version v2c enable
config logging syslog host 192.168.10.15
config logging syslog level notifications
save config

ASUS router (192.168.10.1) — no SNMP, so it only sends syslog. The helper ~/monitoring/scripts/asus_set_syslog.sh logs into the web UI and sets nvram log_ipaddr=192.168.10.15, applied with restart_logger (no reboot). Its metrics come from the SSH pollers instead of Telegraf.

  • The WLC needs a more complex community string than the switches — kept separate as WLC_SNMP_COMMUNITY.
  • All three forward syslog to AxoSyslog on 192.168.10.15:514 (see Syslog pipeline); only the switches and WLC are polled over SNMP by Telegraf.

# Inventory DB & API SQLite · FastAPI · single source of truth

One asset table on 192.168.10.15 is the single source of truth behind the v2 Clients, IP-Plan, Hardware and Network (switch + AP tables, also WiFi) pages — each page is a filtered view of it, so an IP or note reads the same everywhere and editing it once updates every view. Curated fields are hand-edited in the browser; live fields are owned by the poller.

Data flow: client_inventory.py UPSERTs live state by MAC → inventory.db ← browser edits via the API; the inventory-api container serves the DB as JSON to the pages; render_crt.py regenerates the read-only CRT tables from the same DB.

PieceWhereRole
DB~/monitoring/inventory/data/inventory.dbSQLite (WAL) — asset + ip_plan_doc tables; gitignored
APIinventory-api · 127.0.0.1:8090FastAPI container; nginx proxies /v2/api/
Seedinventory/migrate.pyone-time import from the old HTML/JSON, merged by MAC→IP→name
Live writerscripts/client_inventory.pyUPSERTs live fields every 5 min; never touches curated fields
CRT writerinventory/render_crt.pyregenerates CRT tables between <!--GEN:*--> markers

Asset model — the merge key is the MAC (stable; IPs move). ip_static is the assigned IP (NULL = DHCP); a view shows ip_static or, if unset, the live ip_current. Notes are per-pagenotes_ip_plan / notes_hardware / notes_network / notes_clients, so each view carries its own independent note (e.g. the Proxmox RAM-ceiling note lives only on Hardware). type (host / desktop / laptop / spare / given_away / client / switch / ap) selects which view a row appears in — switch/ap drive the Network & WiFi tables, and a hardware form-factor outranks host so e.g. HT-PC shows on Hardware while its ip_static keeps it on IP-Plan. Live fields (online, ip_current, last_seen, switch_port, vendor) are poller-owned and not editable.

Method · path (under /v2/api)Does
GET /clients · /ip-plan · /hardwarethe three page views
GET /assets · /assets?type=switch|apraw asset(s); type= feeds Network/WiFi
POST /assetsadd (sets managed=1)
PATCH /assets/{id}edit curated fields
DELETE /assets/{id}remove
  • Auth: GET is open on the LAN; writes need the shared password — nginx limit_except GET HEAD basic-auth on /v2/api/, user arild, htpasswd at /etc/nginx/.as2_inventory_htpasswd. The API itself listens on 127.0.0.1 only.
  • Editing: the ✎ Edit button on IP-Plan / Hardware / Network makes rows editable (inline edit · + add · 🗑 delete); the Clients roster has an ✎ Edit notes toggle (notes only — it's a discovered roster, so no add/delete). The password is prompted once per session and sent as basic-auth.
  • All IP labels on v2 are DB-driven: the structured tables, the Topology diagram labels (network.js), the Proxmox VM table (live qm list from as2_summary.py with the IP joined from the DB), and even inline prose/header labels — those use <span data-host-ip="Device Name"> filled by app.js fillHostIPs() from /assets (literal IP inside = fallback). Change a device's IP in the DB and every label follows. v2 uses full 192.168.10.x addresses throughout; only the /24 subnet, DHCP-pool ranges and attribute/href URLs stay literal.
  • Concurrency: WAL mode; the container runs as uid 1000 so its -wal/-shm sidecars stay shared with the host poller.
  • CRT inventory pages are read-only, regenerated from the DB each poller cycle (clients.html is generated directly by client_inventory.py).
# rebuild / restart the API
cd ~/monitoring && docker compose up -d --build inventory-api

# re-seed the DB from current data (destructive) · regenerate CRT
python3 ~/monitoring/inventory/migrate.py --write
python3 ~/monitoring/inventory/render_crt.py

# change the edit password
printf 'arild:%s\n' "$(openssl passwd -apr1)" | sudo tee /etc/nginx/.as2_inventory_htpasswd
sudo systemctl reload nginx
Services

# Services

ServiceURLDescription
Homepagehttps://as2.noNginx, local network only
VS Codehttps://code.as2.nocode-server browser IDE
Portainerhttps://portainer.as2.noDocker container manager
Grafanahttps://grafana.as2.noDashboards & logs
dnsmasq192.168.10.15:53LAN DNS resolver
FileZillaftps://192.168.10.3:21212FTPS file server (Server4-Win, VM 101)

# Nginx web server · reverse proxy

Nginx (1.24.0) is the front door for everything on as2.no — it serves the static homepage and reverse-proxies the three other *.as2.no services. Every vhost redirects :80 → :443 and terminates TLS on the shared Let's Encrypt wildcard cert. UFW limits 80/443 to 192.168.10.0/24, so it's reachable on the LAN (or over WireGuard) only.

server_nameServesBackend
as2.no · www.as2.noHomepage (static)root /home/arild/as2-homepage
code.as2.nocode-server IDEproxy → 127.0.0.1:8080
portainer.as2.noPortainerproxy → 127.0.0.1:9000
grafana.as2.noGrafanaproxy → 127.0.0.1:3000
  • Vhosts live in /etc/nginx/sites-available/, enabled via symlinks in /etc/nginx/sites-enabled/ (one file per *.as2.no host).
  • Shared CSP / security headers come from /etc/nginx/snippets/security-headers.conf (main site, script-src 'none'), -v2.conf (v2 dashboard + games allow script-src 'self') and -games.conf.
  • Theme switch: the as2.no vhost reads a theme cookie at /v2 redirects to /v2/; /theme/v2 and /theme/crt set the cookie. Works even for the no-JS CRT site.
  • Live data proxy: /v2/grafana/api/ds/query is a same-origin reverse proxy to 127.0.0.1:3000/api/ds/query so the v2 dashboard fetches interface lists / uptime without CORS (read-only query endpoint only).
  • Inventory API: /v2/api/ proxies to the inventory-api on 127.0.0.1:8090; limit_except GET HEAD puts basic-auth on writes only.
  • Caching: HTML and everything under /v2/ is no-cache (so ?v= cache-busters take effect); css/woff2/svg/ico cache for 7 days.
# Test config, then reload (zero-downtime)
sudo nginx -t
sudo systemctl reload nginx

# Enable a new vhost
sudo ln -s /etc/nginx/sites-available/<name> /etc/nginx/sites-enabled/

# Tail access / error logs
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log

# Dev Stack

  • Web server: Nginx (serves the site + reverse-proxies code/portainer/grafana)
  • Containers: Docker + Docker Compose
  • Container UI: Portainer (Docker container on port 9000)
  • Observability: Grafana · InfluxDB · Loki · Telegraf · AxoSyslog — see Monitoring stack
  • IDE: code-server v4 (port 8080, proxied by Nginx)
  • Runtimes: Node.js v24 LTS, Python 3.12, pip
  • Version control: git + GitHub CLI (gh)

# Docker Workflows

Most services on 192.168.10.15 run as Docker containers, managed with Docker Compose across a few project directories.

StackContainersPurpose
~/wg-easywg-easyWireGuard VPN + web UI
~/dnsmasqdnsmasqLAN DNS resolver
~/monitoringgrafana · influxdb · loki · telegraf · axosyslog · inventory-apiobservability stack + inventory API
standaloneportainerDocker management UI
# List running containers
docker ps

# View resource usage
docker stats

# View logs for a container
docker logs -f <container-name>

# Restart a container
docker compose -f ~/wg-easy/docker-compose.yml restart

# Pull latest image and recreate container
docker compose pull
docker compose up -d

# Remove unused images / volumes
docker system prune

# as2-homepage

This website. Two maintained themes — the classic CRT terminal look and this modern v2 NOC dashboard — built with plain HTML, CSS and a little vanilla JS. No frameworks.

URLhttps://as2.no
StackHTML, CSS, Nginx
Source~/as2-homepage/ (v2 in v2/)
Repogithub.com/arildsorli/as2-homepage (private)
  • Served directly from ~/as2-homepage/ by Nginx.
  • HTTPS via Let's Encrypt DNS-01 challenge.
  • Theme switch is cookie-based; the v2 dashboard pulls live values from the pollers.
cd ~/as2-homepage
# edit files...
git add .
git commit -m "describe change"
git push