Skip to content

Air-gapped and private-CA installs

Verified against v0.10.0 · docker-compose.yml, deploy/charts/parley/values.yaml, deploy/charts/parley/templates/_helpers.tpl, deploy/charts/parley/templates/deployment.yaml, cmd/parley/main.go, internal/auth/oidc.go

Parley has no runtime dependency on the internet. It does not phone home, does not fetch anything at startup beyond what you configure (the OIDC provider’s discovery document, if you use one, and your database), and the frontend is compiled into the binary — there is no CDN it reaches for. Everything below exists to get the image, the chart, and the docs into an air-gapped network once, and to make Parley trust a private certificate authority once it is there.

An operator following this page only can bring up both legs: Compose against a mirrored image, and Helm against a mirrored chart, each talking to an internal IdP and a TLS Postgres over a private CA.

  • Nothing at runtime, once the image is on your registry and the chart is installed. Parley does not call out anywhere it wasn’t told to.
  • A one-time download, from a machine that does have internet access, of: the image (by digest), the Helm chart, and — once available — the release’s Sigstore attestation bundle for offline verification.

If your identity provider or Postgres server is itself reachable only inside the air-gapped network, that’s normal and expected: Parley talks to both over the network you give it, not the internet.

Pick the digest from the release page or the registry, not a mutable tag — see Supply chain for why. The commands below use skopeo copy, which needs no local Docker/Podman daemon and preserves the manifest digest across registries:

Terminal window
digest=sha256:<the digest from the release>
skopeo copy \
--multi-arch all \
docker://ghcr.io/lets-parley/parley@$digest \
docker://internal-registry.example.com/parley/parley@$digest

skopeo was not available in the environment this page’s commands were verified in, so the copy shown below uses podman push against a local registry:2 container instead — the effect (an exact-digest copy landing on a second registry) is the same, but skopeo copy --multi-arch all is the correct tool when it’s installed, since a plain podman push of a tag re-resolves to whatever platform the local Podman is running rather than copying every architecture in the index.

Standing up a throwaway registry to rehearse this:

Terminal window
podman run -d -p 5000:5000 docker.io/library/registry:2

Pulling the current release and pushing it into that registry (TLS verification is off only because the rehearsal registry is plain HTTP on localhost; a real internal registry with a CA-trusted cert must drop --tls-verify=false):

Terminal window
$ podman pull ghcr.io/lets-parley/parley:0.10.0
Trying to pull ghcr.io/lets-parley/parley:0.10.0...
Copying blob sha256:7c12895b777b done
...
Writing manifest to image destination
f7e938e196cc44b62cbb7449e4ac236c83f09395d60562b30af011952cbfd22b
$ podman push --tls-verify=false ghcr.io/lets-parley/parley:0.10.0 \
127.0.0.1:5000/lets-parley/parley:0.10.0
Copying blob sha256:af5aa97ebe6c done
...
Writing manifest to image destination
$ curl -s -H "Accept: application/vnd.oci.image.manifest.v1+json" \
-D - -o /dev/null \
http://127.0.0.1:5000/v2/lets-parley/parley/manifests/0.10.0 \
| grep Docker-Content-Digest
Docker-Content-Digest: sha256:8d5819191dfbb5f6617040800143ee37730464c02aa769306e4ba9897d346bb3

That digest is the manifest for the platform the pulling host resolved (a single-architecture manifest, the same way the image SBOM in Supply chain is generated against one resolved platform manifest out of the published index) — it is a real, reproducible mirror, but confirm you copied every platform you need before relying on it in production; skopeo copy --multi-arch all is the way to guarantee that.

The shipped docker-compose.yml still names ghcr.io and starts a plaintext db. On the air-gapped side, log in to the registry you just filled and apply an overlay that retargets image: at that mirror by digest, mounts the CA, points DATABASE_URL at Postgres that actually presents a certificate, and keeps db from starting.

Terminal window
podman login internal-registry.example.com
# or: docker login internal-registry.example.com

internal/auth/oidc.go uses Go’s default HTTP client for OIDC discovery and token exchange, and the image is distroless with only the public Mozilla root set baked in. An internal Keycloak, Dex, or any identity provider behind an enterprise CA fails TLS verification with no further configuration. Go’s standard library honours SSL_CERT_FILE (and SSL_CERT_DIR) to add trust roots without rebuilding the image, and pgx (Parley’s Postgres driver) honours sslrootcert on the connection string for the same reason on the database leg.

Save the overlay next to the shipped compose file as air-gapped.yml. Compose merges the two: ports (127.0.0.1:8080:8080) and the rest of app come from docker-compose.yml; the overlay replaces image, resets depends_on so the bundled db is not pulled in as a dependency (!reset needs Compose v2.24 or newer), drops DATABASE_ALLOW_PLAINTEXT with !reset null so a value in the operator’s own shell cannot leak back in, and parks db behind a profile so a plain up -d never starts it.

${POSTGRES_PASSWORD} is interpolated from a .env file in that directory (Compose loads .env automatically) — the same variable the shipped file already requires with ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}. Put the password of the external TLS Postgres there, not a password for the bundled db.

Place your CA bundle at ca.pem next to the two compose files — that path is what the overlay bind-mounts; the repository does not ship a CA.

services:
app:
image: internal-registry.example.com/parley/parley@sha256:<the digest from the mirror>
depends_on: !reset []
volumes:
- ./ca.pem:/etc/parley/ca/ca.pem:ro
environment:
DATABASE_ALLOW_PLAINTEXT: !reset null
SSL_CERT_FILE: /etc/parley/ca/ca.pem
BASE_URL: https://parley.example.com
AUTH_MODE: oidc
OIDC_ISSUER: https://idp.internal.example.com/realms/parley
OIDC_CLIENT_ID: parley
DATABASE_URL: postgres://parley:${POSTGRES_PASSWORD}@postgres.internal.example.com:5432/parley?sslmode=verify-full&sslrootcert=/etc/parley/ca/ca.pem
db:
profiles:
- bundled-plaintext
Terminal window
docker compose -f docker-compose.yml -f air-gapped.yml up -d

A confidential-client registration also needs OIDC_CLIENT_SECRET; a public client (PKCE, no secret) does not. Register <BASE_URL>/auth/callback with the provider.

The bundled db service cannot satisfy verify-full. The db service in the project’s docker-compose.yml is the official postgres:16-alpine image. It listens in the clear on the compose bridge; it has no TLS certificate and no way to present one your CA signed. A DATABASE_URL of postgres://…@db:5432/…?sslmode=verify-full will fail to connect. Point at an external Postgres you already run with TLS — the same class of database the Helm chart assumes. The overlay is how “do not start db” is enforced: db remains in the merged project because the shipped file defines it (and app’s published port lives there too), but the bundled-plaintext profile keeps up -d from starting it, and depends_on: !reset [] stops Compose from bringing it up as a dependency. Do not pass --profile bundled-plaintext. The shipped compose file sets DATABASE_ALLOW_PLAINTEXT only because that bundled plaintext db shares a one-host bridge network; the overlay deletes that variable.

Check whether a given release actually published to the OCI registry before assuming helm pull oci://... works — Parley’s release workflow does push the chart to oci://ghcr.io/lets-parley/charts (helm push after helm registry login), so for any release built by that workflow:

Terminal window
helm pull oci://ghcr.io/lets-parley/charts/parley --version <chart-version>
helm push parley-<chart-version>.tgz oci://internal-registry.example.com/charts

If you’re installing from a source checkout instead (or the OCI push failed for a particular release — check the release notes on Supply chain), package it yourself and skip the helm pull:

Terminal window
helm package deploy/charts/parley --destination /tmp
helm push /tmp/parley-*.tgz oci://internal-registry.example.com/charts

Verified against the local registry from above (plain HTTP, hence --plain-http; a real internal registry behind TLS drops that flag):

Terminal window
$ helm package deploy/charts/parley --destination /tmp
Successfully packaged chart and saved it to: /tmp/parley-0.0.0.tgz
$ helm push /tmp/parley-0.0.0.tgz oci://127.0.0.1:5000/charts --plain-http
Pushed: 127.0.0.1:5000/charts/parley:0.0.0
Digest: sha256:94ac52d6546fe09399e27816377f14c37fcd00c504c054673c2f2235ba6902be
$ helm pull oci://127.0.0.1:5000/charts/parley --version 0.0.0 --plain-http \
--destination ./pulled
Pulled: 127.0.0.1:5000/charts/parley:0.0.0
Digest: sha256:94ac52d6546fe09399e27816377f14c37fcd00c504c054673c2f2235ba6902be

(0.0.0 is the placeholder chart version checked into the repository; a released chart carries the real version and appVersion the release workflow stamps in.)

The chart never hardcodes a registry, and it will not render without database.existingSecret_helpers.tpl fails the install rather than produce a Deployment pointing at a secret named "". Create the pull secret, the CA ConfigMap, and the database secret first:

Terminal window
kubectl create secret docker-registry internal-registry-creds \
--docker-server=internal-registry.example.com \
--docker-username=<user> --docker-password=<token>
kubectl create configmap parley-ca --from-file=ca.pem=/path/to/your-ca.pem
kubectl create secret generic parley \
--from-literal=database-url='postgres://parley:secret@postgres.internal.example.com:5432/parley?sslmode=verify-full&sslrootcert=/etc/parley/ca/ca.pem'

The values file below is complete: the mirrored image, baseURL, the required database secret, OIDC (the chart default auth.mode is open, so without these keys the install never talks to an IdP), and the CA mounts. The chart refuses to render auth.mode: oidc unless you set exactly one of auth.oidc.publicClient or auth.oidc.existingSecret.

image:
repository: internal-registry.example.com/parley/parley
digest: "sha256:<the digest from the mirror>"
pullSecrets:
- name: internal-registry-creds
baseURL: https://parley.example.com
database:
existingSecret: parley
auth:
mode: oidc
oidc:
issuer: https://idp.internal.example.com/realms/parley
clientID: parley
publicClient: true
extraVolumes:
- name: parley-ca
configMap:
name: parley-ca
extraVolumeMounts:
- name: parley-ca
mountPath: /etc/parley/ca
readOnly: true
extraEnv:
- name: SSL_CERT_FILE
value: /etc/parley/ca/ca.pem

Save that as my-values.yaml. Log in to the mirrored chart registry, then install:

Terminal window
helm registry login internal-registry.example.com
helm install parley oci://internal-registry.example.com/charts/parley \
--version <chart-version> \
-f my-values.yaml

A confidential-client registration replaces publicClient: true with auth.oidc.existingSecret naming a Secret whose key is oidc-client-secret (the chart’s auth.oidc.secretKey default — never put the secret in values):

Terminal window
kubectl create secret generic parley-oidc \
--from-literal=oidc-client-secret='...'

Then set auth.oidc.existingSecret: parley-oidc and drop publicClient. Register <baseURL>/auth/callback with the provider.

image.pullSecrets renders as the Deployment’s imagePullSecrets. The chart’s extraVolumes/extraVolumeMounts mount the ConfigMap into the container, and extraEnv sets SSL_CERT_FILE for the OIDC leg. The same parley-ca mount is the file sslrootcert=/etc/parley/ca/ca.pem on DATABASE_URL expects. image.digest takes over the app image the same way tests.image.digest takes over the helm test pod: set it to sha256:… and the chart renders repository@digest with no :tag suffix. Leave it empty (the default) and the chart keeps repository:tag.

Every release attaches parley-vX.Y.Z.sigstore.json, from a job holding no registry write. It carries the same signed provenance statement as the GitHub build-provenance attestation (Supply chain), in a form gh attestation verify --bundle can check with no live call to GitHub’s API or transparency log.

Save the bundle from the release and a trusted root while still online, then carry both across the air gap:

Terminal window
gh attestation trusted-root > trusted_root.jsonl # do this once, online

Offline, verify the image you mirrored in by digest — not by tag, since resolving a tag is itself a registry call:

Terminal window
digest=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
gh attestation verify \
"oci://ghcr.io/lets-parley/parley@$digest" \
--owner lets-parley \
--bundle parley-vX.Y.Z.sigstore.json \
--custom-trusted-root trusted_root.jsonl

This was run against the real v0.10.0 release (gh attestation download oci://ghcr.io/lets-parley/parley:0.10.0 --owner Lets-Parley to fetch that release’s bundle) and printed:

Loaded digest sha256:fa4e330933f55903e3d610efe0136cd3c98ad95ec0bc9d960eb59f939f60df35 for oci://ghcr.io/lets-parley/parley@sha256:fa4e330933f55903e3d610efe0136cd3c98ad95ec0bc9d960eb59f939f60df35
Loaded 1 attestations from sha256:fa4e330933f55903e3d610efe0136cd3c98ad95ec0bc9d960eb59f939f60df35.jsonl
The following policy criteria will be enforced:
- Predicate type must match:................ https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:... https://github.com/Lets-Parley
- Subject Alternative Name must match regex: (?i)^https://github\.com/Lets-Parley/
- OIDC Issuer must match:................... https://token.actions.githubusercontent.com
✓ Verification succeeded!
The following 1 attestation matched the policy criteria
- Attestation #1
- Build repo:..... Lets-Parley/Parley
- Build workflow:. .github/workflows/release.yml@refs/tags/v0.10.0
- Signer repo:.... Lets-Parley/Parley
- Signer workflow: .github/workflows/release.yml@refs/tags/v0.10.0

That command downloaded an existing image’s attestation rather than a release asset, since the workflow that attaches the bundle as a release asset shipped after v0.10.0 was cut — the release-asset form carries the identical bundle, just fetched from the release page instead of gh attestation download. The first release published after this change lands is the first to carry parley-vX.Y.Z.sigstore.json as a release asset.

site/ is a static Astro/Starlight site with no runtime backend. Building it needs npm packages once, then nothing:

Terminal window
cd site
npm ci # needs internet (or an npm mirror) the first time
npm run build # fully offline afterward; output is static HTML in site/dist

Serve site/dist with any static file server on the air-gapped network. The landing page (site/src/content/docs/index.mdx) links out to GitHub and other external sites — those links are dead offline by design; they are not removed because the same built site is also served publicly.