# Deployment Options

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

---

Site [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

Cedar Hills Group publishes psLens to the GitHub Container Registry (GHCR) as a **private package**. Each client gets read access of its own, so access can be granted and removed per client.

### Authenticating

1. Cedar Hills Group grants read access to the `pslens` package to a GitHub account: your own (send us the username), or a pull-only account we create for you if you do not use GitHub.
2. On that account, create a personal access token (classic) with only the `read:packages` scope. GHCR accepts classic tokens only; fine-grained tokens do not work for container pulls.
3. On the Docker host:

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

   Docker stores the credentials in `~/.docker/config.json`. They persist across host reboots.
4. Verify the pull works, using the version from your upgrade ticket or the changelog:

   ```bash
   docker pull ghcr.io/cedarhillsgroup/pslens:v1.0.0
   ```

### Image Tags

The release pipeline publishes three 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 |

The client package's `docker-compose.yml` reads the tag from `PSLENS_VERSION` in `.env` and refuses to start without one.

> **Recommended:** Pin production to `vMAJOR.MINOR`. You will automatically pick up patch fixes when running `docker compose pull`. You will avoid unexpected breaking changes from minor or major version bumps.

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

If the Docker host cannot make outbound HTTPS connections to `ghcr.io` (common in segmented enterprise networks), use the air-gapped flow from [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 private registry (Harbor, AWS ECR, Azure ACR, GitLab Registry). Pull the image once, retag, push, and reference the mirrored image in `docker-compose.yml`.

### Troubleshooting Pull Failures

|            Error            |                             Cause                              |                                    Fix                                     |
| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `denied: denied`            | Token expired or revoked                                       | Renew the PAT with Cedar Hills Group                                       |
| `unauthorized`              | Wrong token type or scope, or no package access                | Classic PAT with `read:packages`, on an account granted access to `pslens` |
| `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                        | Review 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 explains what survives `docker compose pull && docker compose up -d` and what does not.

### What Persists, What Doesn't

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

- `/data/nats` — NATS JetStream store. Contains recently-viewed objects, generated markdown reports, and alert state. When using the in-app config UI, it also stores 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 (read 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`. Unless you run `docker volume rm pslens_data`, your data survives image upgrades.

### Three Configuration Modes

There are three ways to source configuration. Pick one based on how many people 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_PSLENS_SWSPQL/"
    username: "PSLENS_API"
    password: "placeholder"      # Overridden by PSLENS_DB_PROD_PASSWORD
    timezone: "America/Chicago"
```

`.env` (sibling of `docker-compose.yml`, `chmod 600`, gitignored; start from the package's `.env.example`):

```bash
PSLENS_VERSION=v1.0.0
PSLENS_DB_PROD_PASSWORD=actual-password-here
PSLENS_MASTER_KEY=64-hex-character-key
```

The `docker-compose.yml` file references `env_file: .env`. Docker injects each 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 -hex 32
```

The key must be 64 hexadecimal characters (32 bytes). psLens refuses to start with any other format.

**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

Back up the data volume with the client package's script, from the folder that holds `docker-compose.yml`:

```bash
./upgrade.sh --backup-only
```

It stops psLens for a few seconds so the data is consistent, writes `backups/pslens-data-<version>-<timestamp>.tar.gz`, and starts psLens again. It uses the psLens image itself, so it works on hosts without internet access. Docker Compose names the volume after your install folder (for example `pslens_pslens_data`), so commands that name `pslens_data` directly back up an empty volume instead. Schedule the script daily with cron.

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`** — commit to your infrastructure-as-code repo (gitignore the password fields, or use the placeholder pattern from Mode B).
- **`.env`** — store in your secrets manager. Never commit this into git.

To restore, run these from the install folder, using the backup file name:

```bash
docker compose stop pslens
docker run --rm --user 0 --volumes-from "$(docker compose ps -aq pslens)" -v "$(pwd)/backups:/backup" \
  --entrypoint bash "ghcr.io/cedarhillsgroup/pslens:$(sed -n 's/^PSLENS_VERSION=//p' .env)" \
  -c 'rm -rf /data/* && tar xzf /backup/pslens-data-v1.0.0-20261021-020000.tar.gz -C /'
docker compose up -d pslens
```

---

## 3. TLS / HTTPS Options

psLens does not serve 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. The table below compares them 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. A container restart picks up new certs.                                       |
| Pros               | Zero external dependencies, single container, familiar to enterprise teams with existing PKI |
| Cons               | You manage the certificate lifecycle. An expired certificate causes 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 requests 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 the HTTP-01 challenge. This requirement 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 handles 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. Certificate state resides 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. An 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:** You must configure the `proxy_buffering off` directive. psLens relies on Server-Sent Events for UI updates. With buffering enabled, the UI appears frozen until pages finish loading.

**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               | Widely deployed reverse proxy with extensive team runbooks. Easy to add request-level customization such as auth and rate limits. |
| Cons               | No built-in certificate automation. Requires more boilerplate than Caddy for the same outcome.                                    |
| 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. The tunnel provider edge handles TLS. psLens remains 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 format. 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 configuration on psLens. No inbound ports opened on the firewall. Mesh networking works well for multi-database connectivity. |
| Cons               | Adds a third-party dependency in the data path. Client policies may restrict cloud tunnels. Providers enforce 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

Cedar Hills Group tells you about each release, with the version to install. Upgrades never change `config.yaml`, and the script backs up the data volume first.

### Standard upgrade

From the folder that holds `docker-compose.yml`:

```bash
./upgrade.sh v1.5.0
```

The script, from the client package:

1. Pulls `ghcr.io/cedarhillsgroup/pslens:v1.5.0`. If the pull fails, nothing else changes.
2. Stops psLens and backs up the data volume to `backups/`.
3. Sets `PSLENS_VERSION=v1.5.0` in `.env`.
4. Starts psLens and waits until `/healthz` reports v1.5.0.
5. Prints the rollback command, and how to restore the backup.

Use an exact version (`v1.5.0`) or a minor version (`v1.5`). The script refuses `latest`. If `PSLENS_VERSION` is a minor version, `docker compose pull pslens && docker compose up -d pslens` also picks up patch releases, without the backup.

### Rollback

Run the script with the version you were running before:

```bash
./upgrade.sh v1.4.2
```

The old image is usually still in the local Docker cache. If the newer release changed stored data, also restore the backup it took (see [Backups](#backups)).

### Air-gapped upgrade

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

# Transfer the .tar.gz to the target host, then:
docker load < pslens-v1.5.0.tar.gz
./upgrade.sh --offline v1.5.0
```

`--offline` skips the pull and stops with an error if the image is not loaded.

### Checking the running version

`GET /healthz` returns the running version and commit:

```json
{"status":"ok","version":"v1.5.0","commit":"3f1c2ab..."}
```

Releases before this change return plain `ok`.

### 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.
