Deployment Options

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

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

    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:

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

Image Tags

The release pipeline publishes three tag flavors for every release:

TagExampleUse when
latestghcr.io/cedarhillsgroup/pslens:latestDev/test only — never pin production here
vMAJOR.MINOR.PATCH:v1.4.2Production — exact reproducibility
vMAJOR.MINOR:v1.4Production — 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:

# 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

ErrorCauseFix
denied: deniedToken expired or revokedRenew the PAT with Cedar Hills Group
unauthorizedWrong token type or scope, or no package accessClassic PAT with read:packages, on an account granted access to pslens
no basic auth credentialsdocker login wasn’t run, or ~/.docker/config.json was lostRe-run docker login ghcr.io
manifest unknownThe tag you asked for doesn’t exist yetReview 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 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.

ModeWhere config livesWhere secrets liveBest for
A. File-onlyconfig.yaml bind-mounted from hostPlaintext in config.yamlInternal-only dev/test
B. File + env overrideconfig.yaml for non-secretsPSLENS_DB_{NAME}_PASSWORD env vars, sourced from .env or a secrets managerRecommended default for client-hosted
C. KV-encryptedMinimal config.yaml. Full config in NATS KV bucket, AES-256 encrypted at rest.Encrypted blob in /data/nats, unlocked by PSLENS_MASTER_KEYMulti-admin setups where you use the in-app config UI

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}_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:

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 scenarioRecommended option
Default — most clientsOption 3: Caddy sidecar
Corporate PKI with certs-as-codeOption 4: nginx sidecar (or Option 1 if minimalist)
Internal-only, small team, already using TailscaleOption 6: Tailscale Serve / Funnel
Already running TraefikOption 5: Traefik
Public internet, single host, no proxy wantedOption 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.

AxisDetail
Certificate sourceCustomer-provided — corporate CA, commercial CA, or self-signed
RenewalCustomer’s responsibility (cron + cert rotation tool)
Hot reloadNot supported. A container restart picks up new certs.
ProsZero external dependencies, single container, familiar to enterprise teams with existing PKI
ConsYou manage the certificate lifecycle. An expired certificate causes an outage.
Best forEnterprises 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.

AxisDetail
Certificate sourceLet’s Encrypt (free, 90-day, auto-renewed at ~60 days)
RenewalFully automatic, in-process
Hot reloadN/A — the library reloads on its own renewal cycle
ProsCheapest TLS, zero ops effort after initial config
ConsRequires port 80 reachable from the public internet for the HTTP-01 challenge. This requirement rules out fully internal deployments.
Best forPublic-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:
AxisDetail
Certificate sourceLet’s Encrypt (public), or Caddy’s built-in CA (tls internal)
RenewalFully automatic. Certificate state resides in the caddy_data volume.
Hot reloadCaddy reloads certs on its own renewal cycle
ProsTrivial config, handles both public-internet and internal-only, decouples TLS from the app (restarting psLens doesn’t drop TLS sessions)
ConsSecond container to operate. An internal CA requires distributing the root cert to clients.
Best forThe 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 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:

  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.

AxisDetail
Certificate sourceCustomer-managed PEM (corporate CA, commercial CA, certbot)
RenewalCustomer’s responsibility (commonly certbot + cron)
Hot reloadYes via nginx -s reload
ProsWidely deployed reverse proxy with extensive team runbooks. Easy to add request-level customization such as auth and rate limits.
ConsNo built-in certificate automation. Requires more boilerplate than Caddy for the same outcome.
Best forClients 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
AxisDetail
Certificate sourceLet’s Encrypt, customer PEM, or Vault
RenewalAutomatic
Hot reloadLive config reload from Docker labels
ProsIf the client already runs Traefik, psLens plugs in with zero net-new ops
ConsAdopting Traefik just for psLens isn’t worth the learning curve
Best forClients 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.

AxisDetail
Certificate sourceTunnel provider (Tailscale or Cloudflare)
RenewalFully automatic
ProsZero TLS configuration on psLens. No inbound ports opened on the firewall. Mesh networking works well for multi-database connectivity.
ConsAdds a third-party dependency in the data path. Client policies may restrict cloud tunnels. Providers enforce rate limits.
Best forInternal-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:

  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:

./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_data volume (see Backups above). If something goes wrong, you can restore the volume and roll the image back to the previous tag.


See Also