Deployment Options
Categories:
Deployment Options
This page is for clients who want to host psLens themselves in a Docker container. It covers three questions in order:
- How do I get the image? Distribution and authentication.
- How do I upgrade without losing my config? Volumes, env vars, and the master key.
- 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 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
Cedar Hills Group grants read access to the
pslenspackage 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.On that account, create a personal access token (classic) with only the
read:packagesscope. GHCR accepts classic tokens only; fine-grained tokens do not work for container pulls.On the Docker host:
echo "YOUR_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdinDocker stores the credentials in
~/.docker/config.json. They persist across host reboots.Verify the pull works, using the version from your upgrade ticket or the changelog:
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 runningdocker 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:
# 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.zipproject 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 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:
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):
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}_PASSWORDwhere{NAME}is the database name fromconfig.yaml, uppercased. For a database namedDEV_HR, the variable isPSLENS_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:
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:
./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:
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.
How it works once implemented:
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:
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 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):
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:
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:
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:
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 offdirective. psLens relies on Server-Sent Events for UI updates. With buffering enabled, the UI appears frozen until pages finish loading.
docker-compose.yml addition:
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:
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):
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):
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:
./upgrade.sh v1.5.0
The script, from the client package:
- Pulls
ghcr.io/cedarhillsgroup/pslens:v1.5.0. If the pull fails, nothing else changes. - Stops psLens and backs up the data volume to
backups/. - Sets
PSLENS_VERSION=v1.5.0in.env. - Starts psLens and waits until
/healthzreports v1.5.0. - 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:
./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).
Air-gapped upgrade
# 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:
{"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_datavolume (see Backups above). If something goes wrong, you can restore the volume and roll the image back to the previous tag.
See Also
- Installation — step-by-step first-time install.
- Configuration — full
config.yamlreference. - Architecture — how the pieces fit together.