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
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
Cedar Hills Group sends you a GitHub fine-grained personal access token (PAT) scoped to
read:packageson thepslenspackage only.On the Docker host:
echo "YOUR_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdinThe credentials are stored in
~/.docker/config.json. They persist across host reboots.Verify the pull works:
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-rundocker 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:
# 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.zipproject 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 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:
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):
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}_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 -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:
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.
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; 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:
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 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):
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; 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:
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 offdirective 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:
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:
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):
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 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:
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:
services:
pslens:
image: ghcr.io/cedarhillsgroup/pslens:v1.4
To upgrade to v1.5: edit the file, then pull and recreate:
# 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
# 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_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.