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.
What needs the internet, and when
Section titled “What needs the internet, and when”- 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.
Mirror the image by digest
Section titled “Mirror the image by digest”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:
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@$digestskopeo 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:
podman run -d -p 5000:5000 docker.io/library/registry:2Pulling 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):
$ podman pull ghcr.io/lets-parley/parley:0.10.0Trying to pull ghcr.io/lets-parley/parley:0.10.0...Copying blob sha256:7c12895b777b done...Writing manifest to image destinationf7e938e196cc44b62cbb7449e4ac236c83f09395d60562b30af011952cbfd22b
$ podman push --tls-verify=false ghcr.io/lets-parley/parley:0.10.0 \ 127.0.0.1:5000/lets-parley/parley:0.10.0Copying 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-DigestDocker-Content-Digest: sha256:8d5819191dfbb5f6617040800143ee37730464c02aa769306e4ba9897d346bb3That 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.
Compose from the mirror
Section titled “Compose from the mirror”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.
podman login internal-registry.example.com# or: docker login internal-registry.example.cominternal/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-plaintextdocker compose -f docker-compose.yml -f air-gapped.yml up -dA 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.
Mirror the chart
Section titled “Mirror the chart”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:
helm pull oci://ghcr.io/lets-parley/charts/parley --version <chart-version>helm push parley-<chart-version>.tgz oci://internal-registry.example.com/chartsIf 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:
helm package deploy/charts/parley --destination /tmphelm push /tmp/parley-*.tgz oci://internal-registry.example.com/chartsVerified against the local registry from above (plain HTTP, hence
--plain-http; a real internal registry behind TLS drops that flag):
$ helm package deploy/charts/parley --destination /tmpSuccessfully 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-httpPushed: 127.0.0.1:5000/charts/parley:0.0.0Digest: sha256:94ac52d6546fe09399e27816377f14c37fcd00c504c054673c2f2235ba6902be
$ helm pull oci://127.0.0.1:5000/charts/parley --version 0.0.0 --plain-http \ --destination ./pulledPulled: 127.0.0.1:5000/charts/parley:0.0.0Digest: 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.)
Install the mirrored chart
Section titled “Install the mirrored chart”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:
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-caextraVolumeMounts: - name: parley-ca mountPath: /etc/parley/ca readOnly: trueextraEnv: - name: SSL_CERT_FILE value: /etc/parley/ca/ca.pemSave that as my-values.yaml. Log in to the mirrored chart registry, then
install:
helm registry login internal-registry.example.comhelm install parley oci://internal-registry.example.com/charts/parley \ --version <chart-version> \ -f my-values.yamlA 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):
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.
Offline provenance verification
Section titled “Offline provenance verification”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:
gh attestation trusted-root > trusted_root.jsonl # do this once, onlineOffline, verify the image you mirrored in by digest — not by tag, since resolving a tag is itself a registry call:
digest=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefgh attestation verify \ "oci://ghcr.io/lets-parley/parley@$digest" \ --owner lets-parley \ --bundle parley-vX.Y.Z.sigstore.json \ --custom-trusted-root trusted_root.jsonlThis 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:fa4e330933f55903e3d610efe0136cd3c98ad95ec0bc9d960eb59f939f60df35Loaded 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.0That 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.
Building the docs site offline
Section titled “Building the docs site offline”site/ is a static Astro/Starlight site with no runtime backend. Building it
needs npm packages once, then nothing:
cd sitenpm ci # needs internet (or an npm mirror) the first timenpm run build # fully offline afterward; output is static HTML in site/distServe 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.