# Deployment Options

LLMS index: [llms.txt](/llms.txt)

---

## Deployment Options

This page is for clients who want to host psLens themselves in a Docker container. It covers three questions in order:

1. **How do I get the image?** Distribution and authentication.
2. **How do I upgrade without losing my config?** Volumes, env vars, and the master key.
3. **How do I do HTTPS?** Six TLS options compared on the same axes.

If you just want a 5-minute install on a private network, the [Installation](/docs/getting-started/installation/) page is enough. Come back here when you're ready to put psLens in front of real users.

---

## 1. Image Distribution

psLens is published to the GitHub Container Registry (GHCR) as a **private package**. Cedar Hills Group issues a read-only token to each client.

### Authenticating

1. Cedar Hills Group sends you a GitHub fine-grained personal access token (PAT) scoped to `read:packages` on the `pslens` package only.
2. On the Docker host:

   ```bash
   echo "YOUR_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
   ```

   The credentials are stored in `~/.docker/config.json`. They persist across host reboots.
3. Verify the pull works:

   ```bash
   docker pull ghcr.io/cedarhillsgroup/pslens:latest
   ```

### Image Tags

The release pipeline publishes four tag flavors for every release:

|         Tag          |                 Example                 |                  Use when                  |
| -------------------- | --------------------------------------- | ------------------------------------------ |
| `latest`             | `ghcr.io/cedarhillsgroup/pslens:latest` | Dev/test only — never pin production here  |
| `vMAJOR.MINOR.PATCH` | `:v1.4.2`                               | Production — exact reproducibility         |
| `vMAJOR.MINOR`       | `:v1.4`                                 | Production — auto-pickup of patch releases |
| Git SHA              | `:a3f8c12`                              | Pinning to a pre-release build             |

> **Recommended:** Pin production to `vMAJOR.MINOR`. You'll automatically pick up patch fixes when you re-run `docker compose pull`, but never get an unexpected breaking change from a minor or major version bump.

### When You Can't Reach `ghcr.io`

If the Docker host can't make outbound HTTPS to `ghcr.io` (common in segmented enterprise networks), use the air-gapped flow documented in [Installation](/docs/getting-started/installation/#air-gapped-environments):

```bash
# On a machine with internet access:
docker pull ghcr.io/cedarhillsgroup/pslens:v1.4.2
docker save ghcr.io/cedarhillsgroup/pslens:v1.4.2 | gzip > pslens-v1.4.2.tar.gz

# Transfer the .tar.gz to the target host (USB, internal artifact repo, etc.), then:
docker load < pslens-v1.4.2.tar.gz
```

You can also mirror the image into your own private registry (Harbor, AWS ECR, Azure ACR, GitLab Registry). Pull it once, retag, push, and reference the mirrored image in your `docker-compose.yml`. Cedar Hills Group is happy to provide a one-time pull script if you need to automate this.

### Troubleshooting Pull Failures

|            Error            |                             Cause                              |                        Fix                        |
| --------------------------- | -------------------------------------------------------------- | ------------------------------------------------- |
| `denied: denied`            | Token expired or revoked                                       | Renew the PAT with Cedar Hills Group              |
| `unauthorized`              | Token has the wrong scope                                      | PAT needs `read:packages` on the `pslens` package |
| `no basic auth credentials` | `docker login` wasn't run, or `~/.docker/config.json` was lost | Re-run `docker login ghcr.io`                     |
| `manifest unknown`          | The tag you asked for doesn't exist yet                        | Check the release notes for available tags        |

---

## 2. Configuration and Secrets

The most failure-prone part of self-hosted deployment is preserving configuration and secrets through upgrades. This section is explicit about what survives `docker compose pull && docker compose up -d` and what doesn't.

### What Persists, What Doesn't

**Persistent (must be on a volume):**

- `/data/nats` — NATS JetStream store. Contains the recently-viewed objects KV, the report store (generated markdown reports), alert state, and, if you use the in-app config UI, the AES-256-encrypted database passwords KV.
- `/data/projects` — project store for uploaded `.zip` project archives.
- `/app/config.yaml` — bind-mounted from the host filesystem.

**Ephemeral (re-created on every container start):**

- Whitelist cache (re-fetched from PeopleSoft on startup).
- PIA URL discovery cache.
- In-memory session state.

The default `docker-compose.yml` in [Installation](/docs/getting-started/installation/#docker-installation) already wires the persistent items correctly: a named volume `pslens_data` mounted at `/data`, and `config.yaml` bind-mounted at `/app/config.yaml:ro`. As long as you don't `docker volume rm pslens_data`, your data survives any number of image upgrades.

### Three Configuration Modes

There are three ways to source configuration. Pick one based on how many people will administer the system and how you manage secrets.

|            Mode            |                               Where config lives                                |                               Where secrets live                               |                       Best for                        |
| -------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------- |
| **A. File-only**           | `config.yaml` bind-mounted from host                                            | Plaintext in `config.yaml`                                                     | Internal-only dev/test                                |
| **B. File + env override** | `config.yaml` for non-secrets                                                   | `PSLENS_DB_{NAME}_PASSWORD` env vars, sourced from `.env` or a secrets manager | **Recommended default for client-hosted**             |
| **C. KV-encrypted**        | Minimal `config.yaml`; full config in NATS KV bucket, AES-256 encrypted at rest | Encrypted blob in `/data/nats`, unlocked by `PSLENS_MASTER_KEY`                | Multi-admin setups where you use the in-app config UI |

#### Mode B example (recommended)

`config.yaml`:

```yaml
server:
  port: 8080
  host: "0.0.0.0"
  appBaseURL: "https://pslens.example.com"
  natsStoreDir: "/data/nats"

databases:
  - name: "PROD"
    description: "Production HCM"
    baseURL: "https://psft.example.com:8000/PSIGW/RESTListeningConnector/PSFT_HR/CHG_SWS_PSOFTQL/"
    username: "PSLENS_API"
    password: "placeholder"      # Overridden by PSLENS_DB_PROD_PASSWORD
    timezone: "America/Chicago"
```

`.env` (sibling of `docker-compose.yml`, `chmod 600`, gitignored):

```bash
PSLENS_DB_PROD_PASSWORD=actual-password-here
PSLENS_MASTER_KEY=base64-encoded-32-byte-key
```

`docker-compose.yml` references `env_file: .env`; Docker injects every variable into the container at startup.

> **Tip:** The env-var override convention is `PSLENS_DB_{NAME}_PASSWORD` where `{NAME}` is the database name from `config.yaml`, uppercased. For a database named `DEV_HR`, the variable is `PSLENS_DB_DEV_HR_PASSWORD`.

#### About `PSLENS_MASTER_KEY`

In production, psLens **requires** `PSLENS_MASTER_KEY` to be set. It's used to encrypt database passwords stored in the NATS KV bucket. Generate one once:

```bash
openssl rand -base64 32
```

**Critical:** back this key up out-of-band in your password manager, AWS Secrets Manager, HashiCorp Vault, or wherever you keep root-of-trust secrets. If you lose the master key, the encrypted password blob in `/data/nats` becomes unrecoverable and you'll have to re-enter every database password.

### Backups

Daily backup of the data volume is one line:

```bash
docker run --rm \
  -v pslens_data:/data \
  -v $(pwd):/backup \
  alpine tar czf /backup/pslens-data-$(date +%F).tar.gz -C / data
```

What to back up where:

- **Data volume** (`pslens_data`) — daily tarball, retain 14-30 days. Captures reports, alert state, and encrypted passwords KV.
- **`config.yaml`** — check into your infrastructure-as-code repo (gitignore the password fields, or use the placeholder pattern from Mode B).
- **`.env`** — store in your secrets manager. Never check this into git.

To restore: stop psLens, `docker volume create pslens_data`, untar into the volume, restart.

---

## 3. TLS / HTTPS Options

psLens does not terminate TLS in the binary by default. It listens on plain HTTP and expects either a reverse proxy, an in-binary TLS configuration, or a tunnel to provide HTTPS.

There are six viable options. They're compared below on the same axes: certificate source, automation, operational complexity, and the scenario each fits best.

### Quick recommendation

|                   Your scenario                    |                   Recommended option                    |
| -------------------------------------------------- | ------------------------------------------------------- |
| Default — most clients                             | **Option 3: Caddy sidecar**                             |
| Corporate PKI with certs-as-code                   | **Option 4: nginx sidecar** (or Option 1 if minimalist) |
| Internal-only, small team, already using Tailscale | **Option 6: Tailscale Serve / Funnel**                  |
| Already running Traefik                            | **Option 5: Traefik**                                   |
| Public internet, single host, no proxy wanted      | **Option 2: in-binary autocert**                        |

Details on each option follow.

### Option 1: Go-native TLS via `crypto/tls` (cert files)

psLens loads a PEM cert + key from disk and serves TLS directly. No reverse proxy, no extra container, no external dependencies.

> **Status:** This requires a small code change to psLens (currently the binary only listens on plain HTTP). Contact Cedar Hills Group if you need this option — it's roughly 30 lines of Go and a config block. Tracked in the [backlog](https://github.com/cedarhillsgroup/pslens/issues).

**How it works once implemented:**

```yaml
server:
  tls:
    enabled: true
    certFile: "/certs/pslens.crt"
    keyFile: "/certs/pslens.key"
```

Mount `/certs` as a read-only bind from the host where your PKI tooling drops renewed certs.

|        Axis        |                                            Detail                                            |
| ------------------ | -------------------------------------------------------------------------------------------- |
| Certificate source | Customer-provided — corporate CA, commercial CA, or self-signed                              |
| Renewal            | Customer's responsibility (cron + cert rotation tool)                                        |
| Hot reload         | Not supported; container restart picks up new certs                                          |
| Pros               | Zero external dependencies, single container, familiar to enterprise teams with existing PKI |
| Cons               | You manage the cert lifecycle; an expired cert means an outage                               |
| Best for           | Enterprises with internal PKI tooling (Venafi, AWS ACM Private CA, Vault PKI)                |

### Option 2: Go-native TLS via `acme/autocert` (Let's Encrypt)

psLens fetches and renews Let's Encrypt certs in-process using the `golang.org/x/crypto/acme/autocert` library.

> **Status:** Like Option 1, this requires a small code addition to psLens. Contact Cedar Hills Group.

**How it works once implemented:**

```yaml
server:
  tls:
    autocert:
      enabled: true
      hostnames: ["pslens.example.com"]
      email: "admin@example.com"
      cacheDir: "/data/acme"
```

Cert cache lives in `/data/acme` so it survives container restarts as long as the `pslens_data` volume does. psLens listens on `:80` for the ACME HTTP-01 challenge and `:443` for TLS.

|        Axis        |                                                     Detail                                                      |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| Certificate source | Let's Encrypt (free, 90-day, auto-renewed at ~60 days)                                                          |
| Renewal            | Fully automatic, in-process                                                                                     |
| Hot reload         | N/A — the library reloads on its own renewal cycle                                                              |
| Pros               | Cheapest TLS, zero ops effort after initial config                                                              |
| Cons               | Requires port 80 reachable from the public internet for HTTP-01 challenge; rules out fully internal deployments |
| Best for           | Public-internet hosts on a real domain (`pslens.client.com`)                                                    |

### Option 3: Caddy sidecar

Run [Caddy](https://caddyserver.com/) as a second service in the same `docker-compose.yml`. Caddy terminates TLS and reverse-proxies to psLens on the internal Docker network.

**Caddyfile** (5 lines for the public-internet case):

```caddy
pslens.example.com {
    reverse_proxy pslens:8080
    encode gzip
}
```

For an **internal-only** deployment (no public DNS, no Let's Encrypt), use Caddy's built-in CA:

```caddy
pslens.internal.example.com {
    tls internal
    reverse_proxy pslens:8080
}
```

You'll need to add Caddy's root cert to client browsers (push it via MDM) so they trust the internal cert.

**docker-compose.yml addition:**

```yaml
services:
  pslens:
    image: ghcr.io/cedarhillsgroup/pslens:v1.4
    expose:
      - "8080"      # no longer "ports:" — only Caddy needs an external port
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - pslens_data:/data
    env_file: .env
    restart: unless-stopped

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

volumes:
  pslens_data:
  caddy_data:
  caddy_config:
```

|        Axis        |                                                                  Detail                                                                  |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Certificate source | Let's Encrypt (public), or Caddy's built-in CA (`tls internal`)                                                                          |
| Renewal            | Fully automatic; cert state in the `caddy_data` volume                                                                                   |
| Hot reload         | Caddy reloads certs on its own renewal cycle                                                                                             |
| Pros               | Trivial config, handles both public-internet and internal-only, decouples TLS from the app (restarting psLens doesn't drop TLS sessions) |
| Cons               | Second container to operate; internal CA requires distributing the root cert to clients                                                  |
| Best for           | **The default recommended option for most client deployments**                                                                           |

### Option 4: nginx sidecar

Same shape as Caddy but with nginx, using customer-provided cert files.

**nginx.conf:**

```nginx
events {}

http {
    server {
        listen 443 ssl http2;
        server_name pslens.example.com;

        ssl_certificate     /certs/pslens.crt;
        ssl_certificate_key /certs/pslens.key;
        ssl_protocols       TLSv1.2 TLSv1.3;

        location / {
            proxy_pass         http://pslens:8080;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Proto $scheme;

            # Server-Sent Events: disable buffering for the SSE endpoints
            proxy_buffering    off;
            proxy_cache        off;
        }
    }

    server {
        listen 80;
        server_name pslens.example.com;
        return 301 https://$host$request_uri;
    }
}
```

> **Important for psLens:** the `proxy_buffering off` directive is required. psLens relies on Server-Sent Events for most of the UI; with buffering enabled, the UI will appear frozen until pages finish loading entirely.

**docker-compose.yml addition:**

```yaml
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/certs:ro
    restart: unless-stopped
```

Cert renewal is a separate concern, typically certbot run as a host cron job that replaces the files in `./certs/` and signals nginx with `docker compose exec nginx nginx -s reload`.

|        Axis        |                                                                            Detail                                                                             |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Certificate source | Customer-managed PEM (corporate CA, commercial CA, certbot)                                                                                                   |
| Renewal            | Customer's responsibility (commonly certbot + cron)                                                                                                           |
| Hot reload         | Yes via `nginx -s reload`                                                                                                                                     |
| Pros               | The most-deployed reverse proxy on earth; every enterprise ops team has nginx runbooks; easy to add request-level customization (auth, rate-limits, rewrites) |
| Cons               | No built-in cert automation; more boilerplate than Caddy for the same outcome on the happy path                                                               |
| Best for           | Clients who already standardize on nginx, or who need request-level customization                                                                             |

### Option 5: Traefik sidecar

Same shape as Caddy but Traefik discovers routes from Docker labels on the psLens service. Useful only if the client already runs Traefik.

**docker-compose.yml addition:**

```yaml
  pslens:
    # ... rest of config ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.pslens.rule=Host(`pslens.example.com`)"
      - "traefik.http.routers.pslens.tls.certresolver=letsencrypt"
      - "traefik.http.services.pslens.loadbalancer.server.port=8080"

  traefik:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - --certificatesresolvers.letsencrypt.acme.email=admin@example.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik_data:/letsencrypt
    restart: unless-stopped
```

|        Axis        |                                  Detail                                   |
| ------------------ | ------------------------------------------------------------------------- |
| Certificate source | Let's Encrypt, customer PEM, or Vault                                     |
| Renewal            | Automatic                                                                 |
| Hot reload         | Live config reload from Docker labels                                     |
| Pros               | If the client already runs Traefik, psLens plugs in with zero net-new ops |
| Cons               | Adopting Traefik just for psLens isn't worth the learning curve           |
| Best for           | Clients who already use Traefik (common in Kubernetes shops)              |

### Option 6: Tailscale Serve / Funnel (or Cloudflare Tunnel)

Bypass TLS-at-psLens entirely by exposing the service over a managed tunnel. TLS terminates at the tunnel provider's edge; psLens stays on plain HTTP inside the tunnel.

**Tailscale Serve** (private to your tailnet — internal use):

```bash
tailscale serve --bg --https 443 http://localhost:8080
```

You'll get a URL like `https://pslens.tailnet-name.ts.net`. Tailscale issues and renews the cert. Only members of your tailnet can reach it.

**Tailscale Funnel** (public internet via Tailscale's edge):

```bash
tailscale funnel --bg 443
```

Same URL shape; reachable from the public internet but rate-limited and not designed for high-volume traffic. Fine for an admin dashboard.

**Cloudflare Tunnel** (public, no inbound ports):

Install `cloudflared` on the Docker host (or run it as a sidecar container). Authenticate, create a tunnel, point a Cloudflare-managed DNS name at it.

|        Axis        |                                                                   Detail                                                                    |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Certificate source | Tunnel provider (Tailscale or Cloudflare)                                                                                                   |
| Renewal            | Fully automatic                                                                                                                             |
| Pros               | Zero TLS config on the psLens side; no inbound ports opened on the firewall; mesh networking (Tailscale) is great for multi-DB connectivity |
| Cons               | Adds a third-party dependency in the data path; some clients have policies against cloud tunnels for compliance-relevant tools; rate limits |
| Best for           | Internal-only deployments where you want easy access for a small team without standing up a reverse proxy or opening firewall ports         |

---

## 4. Upgrades

The upgrade flow depends on whether you can reach `ghcr.io` and whether you're pinning to a specific version. The data volume and `config.yaml` are untouched in all three cases.

### Standard (online) upgrade

If you pinned to `:latest` or to a `vMAJOR.MINOR` tag that's auto-receiving patch fixes:

```bash
cd /opt/pslens
docker compose pull pslens
docker compose up -d pslens
```

`pull` fetches the new image; `up -d` recreates the psLens container with the new image and reattaches the existing volume and config. Data is preserved.

### Pinned-version upgrade (recommended for production)

Pin to a specific tag in `docker-compose.yml`:

```yaml
services:
  pslens:
    image: ghcr.io/cedarhillsgroup/pslens:v1.4
```

To upgrade to v1.5: edit the file, then pull and recreate:

```bash
# Edit docker-compose.yml: v1.4 to v1.5
docker compose pull pslens
docker compose up -d pslens
```

**Rollback is a one-line edit** back to the previous tag, then `docker compose up -d pslens` again. The old image is still in the local Docker cache (unless you ran `docker image prune` in between).

### Air-gapped upgrade

```bash
# On a machine with internet:
docker pull ghcr.io/cedarhillsgroup/pslens:v1.5
docker save ghcr.io/cedarhillsgroup/pslens:v1.5 | gzip > pslens-v1.5.tar.gz

# Transfer the .tar.gz to the target host, then:
docker load < pslens-v1.5.tar.gz
docker compose up -d pslens
```

### Cedar Hills Group's breaking-change contract

- **Stable across minor versions:** env-var names (`PSLENS_DB_{NAME}_PASSWORD`, `PSLENS_MASTER_KEY`), volume mount paths (`/data`, `/app/config.yaml`), and the data on disk.
- **Documented in `CHANGELOG.md`:** any config schema change. Schema changes happen on major version bumps.
- **Automatic:** NATS KV bucket schema migrations run on first start of a new version.

> **Before a major-version upgrade:** always take a backup of the `pslens_data` volume (see [Backups](#backups) above). If something goes wrong, you can restore the volume and roll the image back to the previous tag.

---

## See Also

- [Installation](/docs/getting-started/installation/) — step-by-step first-time install.
- [Configuration](/docs/getting-started/configuration/) — full `config.yaml` reference.
- [Architecture](/docs/getting-started/architecture/) — how the pieces fit together.
