---
name: proxmox-homelab
description: "Create and manage Proxmox VE containers (LXC) and VMs via CLI. Covers CT creation, storage/network discovery, SSH access hygiene, and common self-hosted service patterns like Pi-hole and Tailscale."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux]
metadata:
  hermes:
    tags: [Proxmox, LXC, VM, pct, pvesh, homelab, self-hosted, Tailscale, networking, CLI]
    related_skills: [media-streaming]
    category: devops
---

# Proxmox Homelab

Create, configure, and maintain Proxmox VE containers and VMs from the command line without using the web UI.

## 1. Pre-flight Checks

Before creating anything, gather these four facts from the target node:

### Next available CT ID

```bash
pvesh get /cluster/nextid
```

### Storage that accepts templates

```bash
pvesh get /nodes/<NODE>/storage --output-format json
```

Look for `"vztmpl"` in the `content` field. The storage ID (e.g. `hdd-data`) is what you pass to `pct create`.

### Available OS templates

```bash
ls -la /var/lib/vz/template/cache/
```

Proxmox only ships a few templates by default. If the desired template is missing, download it via the web UI (Node -> Local -> CT Templates) or with:

```bash
pveam update
pveam available --section system
pveam download <STORAGE> debian-12-standard_12.x-x_amd64.tar.zst
```

### Network bridge

```bash
pvesh get /nodes/<NODE>/network --output-format json
```

Look for `"type":"bridge"`. The `iface` value (e.g. `vmbr0`) is passed to `pct create --net0`.

> **Note:** `jq` may not be installed on the Proxmox host. Either install it (`apt install jq`) or parse the JSON inline with Python/`grep`/`sed`.

---

## 2. Creating a Container

### Basic command

```bash
pct create <VMID> <TEMPLATE_PATH> \
  --hostname <HOSTNAME> \
  --storage <STORAGE> \
  --rootfs <STORAGE>:<SIZE_GB> \
  --cores <N> --memory <MB> \
  --net0 name=eth0,bridge=<BRIDGE>,ip=dhcp \
  --features nesting=1,keyctl=1 \
  --unprivileged 1 \
  --onboot 1
```

### Common flags explained

| Flag | Purpose |
|------|---------|
| `--unprivileged 1` | Safer default; prevents host root escalation |
| `--features nesting=1` | Allows Docker/other containers inside the CT |
| `--features keyctl=1` | Required for some modern services (Tailscale, systemd) |
| `--onboot 1` | Start automatically when Proxmox boots |

### After creation

```bash
pct start <VMID>
pct exec <VMID> -- bash
# Inside the CT now
```

---

## 3. Post-Creation Patterns

### 3.1 Tailscale in an LXC

1. Enable `nesting=1,keyctl=1` at creation (or edit the `.conf` file later).
2. Inside the CT:
   ```bash
   curl -fsSL https://tailscale.com/install.sh | sh
   tailscale up
   ```
3. The `tailscale up` command prints an auth URL. The user must open that URL on their phone/laptop to approve the node.
4. Verify with `tailscale status` and `ip addr show tailscale0`.

**Pitfall — unprivileged LXC without TUN device:**
On unprivileged containers, the TUN device may not be available. The systemd `tailscaled.service` will fail with exit code 1. Two workarounds:

- **Add TUN to the CT config (persistent):**
  ```bash
  echo "lxc.cgroup2.devices.allow = c 10:200 rwm" >> /etc/pve/lxc/<VMID>.conf
  echo "lxc.mount.entry = /dev/net/tun dev/net/tun none bind,create=file" >> /etc/pve/lxc/<VMID>.conf
  pct reboot <VMID>
  ```

- **Run tailscaled in userspace mode (quick, no reboot):**
  ```bash
  systemctl stop tailscaled
  TS_USERSPACE=1 tailscaled --socket=/var/run/tailscale/tailscaled.sock --state=/var/lib/tailscale/tailscaled.state --port=41641 &
  tailscale up --socket=/var/run/tailscale/tailscaled.sock --hostname=<name>
  ```
  Userspace mode does not create a `tailscale0` interface. It relies on SOCKS5/HTTP proxy forwarding, but `tailscale up` and `tailscale status` work via `--socket`.

### 3.2 Pi-hole on Proxmox

Pi-hole works well inside an unprivileged Debian/Ubuntu CT with Tailscale:

1. Create the CT (Section 2).
2. Install Tailscale (Section 3.1). If the CT is unprivileged, you **must** add the TUN device to the CT config first or Tailscale will fail to start.
3. Install Pi-hole:
   ```bash
   curl -sSL https://install.pi-hole.net | bash
   ```
   Pi-hole v6 migrates legacy `setupVars.conf` into `/etc/pihole/pihole.toml` automatically during install. Unattended installs work via `| bash -s -- --unattended` but the password in `setupVars.conf` is hashed during migration; set a clear web password afterward with `pihole setpassword`.
4. Configure Pi-hole to listen on all interfaces so it answers queries arriving over Tailscale:
   ```bash
   sed -i 's/listeningMode = "LOCAL"/listeningMode = "ALL"/' /etc/pihole/pihole.toml
   systemctl restart pihole-FTL
   ```
   Or use the web UI Settings -> DNS -> Interface listening behavior -> Listen on all interfaces.
5. In the Tailscale admin console, set the CT's 100.x.x.x IP as the **Global nameserver** for the tailnet.
6. **Change upstream DNS servers** (Pi-hole v6 uses `pihole.toml`, not `setupVars.conf`):
   ```bash
   # Edit the file directly
   sed -i 's/"8.8.8.8"/"9.9.9.9"/' /etc/pihole/pihole.toml
   systemctl restart pihole-FTL
   ```
   There is no `pihole setdns` CLI command in v6.

> **Why Tailscale inside the CT?** It gives Pi-hole a stable 100.x address that every tailnet device can reach, regardless of the CT's LAN DHCP lease.

---

## 3.3 Plausible Analytics in an LXC

Plausible is a lightweight, privacy-focused analytics platform. It runs via Docker Compose inside a Debian/Ubuntu CT.

**Prerequisites:** `nesting=1` (for Docker) and TUN device if using Tailscale.

**Inside the CT:**
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
mkdir -p ~/plausible && cd ~/plausible

# Docker Compose stack
cat > docker-compose.yml << 'EOF'
version: "3.8"
services:
  plausible_db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=changeme

  plausible_events_db:
    image: clickhouse/clickhouse-server:24-alpine
    volumes:
      - event-data:/var/lib/clickhouse

  plausible:
    image: plausible/analytics:latest
    ports:
      - "8000:8000"
    depends_on:
      - plausible_db
      - plausible_events_db
    environment:
      - DATABASE_URL=postgres://postgres:changeme@plausible_db:5432/plausible
      - CLICKHOUSE_DATABASE_URL=http://plausible_events_db:8123/plausible
      - SECRET_KEY_BASE=$(openssl rand -base64 48 | tr -d '\n')
      - BASE_URL=https://analytics.yourdomain.com
      - DISABLE_REGISTRATION=true
EOF

docker compose up -d
```

**Access:** Point a reverse proxy (Caddy, Nginx) or Tailscale Funnel to `localhost:8000`. Generate the admin user by visiting the setup URL once.

---

## 3.4 BookStack in an LXC

BookStack is a self-hosted wiki with shelf/book/chapter hierarchy. Ideal for D&D campaigns, documentation, and knowledge bases.

**Via Docker Compose (recommended):**
```bash
mkdir -p ~/bookstack && cd ~/bookstack

cat > docker-compose.yml << 'EOF'
version: "3.8"
services:
  bookstack_db:
    image: lscr.io/linuxserver/mariadb
    environment:
      - PUID=1000
      - PGID=1000
      - MYSQL_ROOT_PASSWORD=changeme
      - MYSQL_DATABASE=bookstack
      - MYSQL_USER=bookstack
      - MYSQL_PASSWORD=changeme
    volumes:
      - db-data:/config

  bookstack:
    image: lscr.io/linuxserver/bookstack
    ports:
      - "6875:80"
    depends_on:
      - bookstack_db
    environment:
      - PUID=1000
      - PGID=1000
      - APP_URL=https://wiki.yourdomain.com
      - DB_HOST=bookstack_db
      - DB_PORT=3306
      - DB_USER=bookstack
      - DB_PASS=changeme
      - DB_DATABASE=bookstack
    volumes:
      - app-data:/config
EOF

docker compose up -d
```

**First login:** `admin@admin.com` / `password`. Change immediately via web UI.

**Pitfall — `APP_URL` must match the public URL:**
If you expose BookStack via Tailscale Funnel or a reverse proxy, the `APP_URL` **must** be the public HTTPS address (e.g. `https://bookstack.tailXXXXX.ts.net`). If it remains `http://localhost:6875`, browsers will load the HTML but try to fetch CSS/JS from `http://localhost:6875` on the user's own machine, resulting in a broken unstyled page.

**Fix:**
```bash
# Update both docker-compose.yml and the live .env for the change to persist across restarts
sed -i 's|APP_URL=.*|APP_URL=https://bookstack.tailXXXXX.ts.net|' /opt/bookstack/docker-compose.yml
docker exec bookstack sed -i 's|APP_URL=.*|APP_URL=https://bookstack.tailXXXXX.ts.net|' /config/www/.env
docker compose down && docker compose up -d
```

**Wiki.js alternative:** If you need Markdown-native editing, graph view, and comments, use Wiki.js instead. It also runs via Docker Compose with a PostgreSQL or SQLite backend. See `references/proxmox-ct-self-hosted-services.md` for a Wiki.js compose file and additional self-hosted service patterns.

### CT SSH Key Setup (so Hermes can manage the LXC directly)

After creating the CT, copy the Hermes public key into the CT so future access is frictionless:

```bash
# On Proxmox host, push the Hermes public key into the CT
pct exec <VMID> -- bash -c 'mkdir -p /root/.ssh && chmod 700 /root/.ssh'
echo '<hermes-public-key>' | pct exec <VMID> -- bash -c 'cat > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys'
pct exec <VMID> -- sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
pct exec <VMID> -- systemctl restart sshd
```

Then from Hermes, connect directly:
```bash
ssh -i ~/.ssh/id_ed25519_imac_proxmox root@<ct-ip>
```

> **Finding the CT IP address:** `pct exec <VMID> -- ip -4 addr show eth0`

> **Security note:** The key used here is the one already trusted on the Proxmox host. It provides the same access level as `pct exec` but avoids Proxmox mediation on every command.

> **Pitfall:** `pct push <vmid> <local> <dest>` does **not** support a `--mode` option. If you need to set permissions on a pushed file, use `pct exec <VMID> -- chmod ...` afterward.

---

## 4. SSH Access Hygiene

- Proxmox nodes often only allow `root` login via key auth. Use `root@<host>` explicitly.
- If the user's Hermes session has a dedicated key (e.g. `id_ed25519_imac_proxmox`), always specify it with `ssh -o IdentitiesOnly=yes -i <path>`.
- If the key is rejected, show the user the **public key** and ask them to add it to `/root/.ssh/authorized_keys` on the host. Never type passwords into SSH prompts.
- Ask permission before every non-Hermes action and before destructive commands (stop, destroy, resize).
- Never expose `authorized_keys` contents, API tokens, or secrets in tool output.

---

## 5. Proxmox LXC-Specific Notes

Many of the practical LXC operations (bind mounts, GPU passthrough, disk expansion) are already documented in `media-streaming`. When the task is media-specific, prefer that skill. For general CT creation and networking, use this skill.

---

## References

- `references/proxmox-ct-tailscale-pihole.md` — Complete session transcript covering CT creation, SSH key troubleshooting, TUN device configuration for Tailscale in unprivileged LXC, Pi-hole v6 installation (including v6-specific `pihole.toml` changes and the absence of `setdns` CLI), and Tailscale global DNS setup.
- `references/proxmox-ct-self-hosted-services.md` — Docker Compose recipes for Plausible Analytics, BookStack, Wiki.js, and other self-hosted services. Includes CT resource guidelines, backup strategy, and reverse proxy patterns.

## Related Skills

- `media-streaming` — for Jellyfin-specific Proxmox LXC operations, remuxing, and hardware transcoding.
