Deployment Options

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

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:

    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:

    docker pull ghcr.io/cedarhillsgroup/pslens:latest
    

Image Tags

The release pipeline publishes four 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
Git SHA:a3f8c12Pinning 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:

# 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

ErrorCauseFix
denied: deniedToken expired or revokedRenew the PAT with Cedar Hills Group
unauthorizedToken has the wrong scopePAT needs read:packages on the pslens package
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 yetCheck 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 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.

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 restEncrypted 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_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}_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 -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 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; container restart picks up new certs
ProsZero external dependencies, single container, familiar to enterprise teams with existing PKI
ConsYou manage the cert lifecycle; an expired cert means 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 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.

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 HTTP-01 challenge; 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 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:
AxisDetail
Certificate sourceLet’s Encrypt (public), or Caddy’s built-in CA (tls internal)
RenewalFully automatic; cert state 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; 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: 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:

  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
ProsThe most-deployed reverse proxy on earth; every enterprise ops team has nginx runbooks; easy to add request-level customization (auth, rate-limits, rewrites)
ConsNo built-in cert automation; more boilerplate than Caddy for the same outcome on the happy path
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. 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.

AxisDetail
Certificate sourceTunnel provider (Tailscale or Cloudflare)
RenewalFully automatic
ProsZero TLS config on the psLens side; no inbound ports opened on the firewall; mesh networking (Tailscale) is great for multi-DB connectivity
ConsAdds a third-party dependency in the data path; some clients have policies against cloud tunnels for compliance-relevant tools; 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

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.

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