Skip to main content
Version: 0.4.x

Production Deployment

Private distribution — not yet open source

The W'xOps Portal container image and the supporting Helm charts are hosted on a private registry and are not publicly available.

ResourceRegistryStatus
Portal container imagegitea.xeusnguyen.xyz/platform-team/wxops-portal-v2Private
Helm charts (pinniped, portal)https://kubewekend.xeusnguyen.xyzPublic

An open-source release is planned. Until then, access is granted on a case-by-case basis for enterprise deployments.

To request access or schedule a demo: contact the platform team directly — see the footer of this page for links. Or submited Enterprise Form for quick connecting and demo.


Prerequisites

The following must be in place on the hub cluster before deploying the portal:

ComponentWhy it's needed
ArgoCDManages all platform applications including the portal itself
Traefik (ingress controller)TCP TLS passthrough for Pinniped; HTTP ingress for portal
cert-manager + a ClusterIssuerTLS certificates for Pinniped and portal (chart uses ca-issuer by default)
External Secrets OperatorSyncs portal environment variables from Vault into a K8s Secret
VaultStores portal secrets at secret/platform/service-portal
Dex (or another OIDC IdP)Upstream identity provider federated through Pinniped Supervisor
GiteaHosts the gitops-infra repo (service catalog + scaffolded overlays)
A private registry pull secret (regcred) in the platform namespaceRequired to pull the portal image from the private Gitea registry

Architecture overview


Step 1 — Deploy Pinniped

Pinniped is deployed via Helm using the private chart from kubewekend.xeusnguyen.xyz. In the reference gitops setup it is managed by the security-plane ApplicationSet at sync wave 2, after Vault and cert-manager are ready.

1a — Add the Helm repository

helm repo add kubewekend https://kubewekend.xeusnguyen.xyz
helm repo update
Private repository

This Helm repository requires credentials. Contact the platform team for access.

1b — Configure values.yaml

The chart wraps both Pinniped Concierge and Supervisor under a single pinniped: key. Below is the minimal production configuration for a Traefik + cert-manager cluster:

pinniped:
enabled: true
installCRDs: true

namespace:
create: true
name: "" # defaults to "pinniped" → namespaces: pinniped-supervisor, pinniped-concierge

concierge:
enabled: true
replicaCount: 1

resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 100m, memory: 128Mi }

config:
noProxy: $(KUBERNETES_SERVICE_HOST),169.254.169.254,127.0.0.1,localhost,.svc,.cluster.local

# Impersonation proxy — ClusterIP mode (no public LoadBalancer needed)
credentialIssuer:
create: true
impersonationProxy:
mode: enabled
externalEndpoint: "pinniped-concierge-proxy.pinniped-concierge.svc.cluster.local"
service:
type: ClusterIP

# TLS via cert-manager using your existing ClusterIssuer
tls:
certManager:
enabled: true
issuerRef:
name: ca-issuer # ← replace with your ClusterIssuer name
kind: ClusterIssuer
group: cert-manager.io
certificate:
enabled: true
secretName: concierge-tls-secret
dnsNames:
- "pinniped-concierge-proxy.pinniped-concierge.svc.cluster.local"

# TCP TLS passthrough via Traefik
ingress:
enabled: true
provider: traefik
hostname: "pinniped-concierge-proxy.pinniped-concierge.svc.cluster.local"
traefik:
entryPoints: [websecure]
tlsPassthrough: true

# JWTAuthenticator — validates tokens issued by the Supervisor
jwtAuthenticator:
create: true
name: dex-jwt-authenticator
issuer: "https://pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local"
audience: "k3s-cluster" # ← must be unique per cluster
certificateAuthorityDataSource:
kind: Secret
name: concierge-tls-secret
key: ca.crt

supervisor:
enabled: true
replicaCount: 1

resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 1000m, memory: 128Mi }

config:
noProxy: $(KUBERNETES_SERVICE_HOST),169.254.169.254,127.0.0.1,localhost,.svc,.cluster.local

# TLS via cert-manager
tls:
certManager:
enabled: true
issuerRef:
name: ca-issuer
kind: ClusterIssuer
group: cert-manager.io
certificate:
enabled: true
secretName: supervisor-tls-secret
dnsNames:
- "pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local"

# ClusterIP service for internal access
service:
public:
clusterIP:
enabled: true
port: 443

# TCP TLS passthrough via Traefik for browser OIDC flow
ingress:
enabled: true
provider: traefik
hostname: "pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local"
traefik:
entryPoints: [websecure]
tlsPassthrough: true
tlsSecretName: supervisor-tls-secret

# FederationDomain — issuer URL must exactly match jwtAuthenticator.issuer
federationDomain:
create: true
issuer: "https://pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local"
tlsSecretName: supervisor-tls-secret

# Identity provider — Dex (swap for github or ldap if needed)
identityProviders:
dex:
create: true
name: dex
displayName: "Dex OIDC Authenticator"
issuer: "https://idp.example.com" # ← your Dex issuer URL
additionalScopes: [offline_access, groups, email]
usernameClaim: email
groupsClaim: groups
createClientSecret: true
clientID: "pinniped-supervisor"
clientSecret: "replace-with-real-secret" # ← store in Vault, inject via ESO

1c — Install or sync via ArgoCD

Direct Helm install:

helm upgrade --install pinniped kubewekend/pinniped \
--namespace pinniped-supervisor \
--create-namespace \
-f values.yaml

Via ArgoCD Application (recommended):

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: security-pinniped
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
project: infrastructure
source:
repoURL: https://gitea.example.com/platform-team/gitops-infra.git
targetRevision: main
path: base/security-plane/pinniped
helm:
releaseName: pinniped
valueFiles: [values.yaml]
destination:
server: https://kubernetes.default.svc
namespace: pinniped-supervisor
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [CreateNamespace=true]

1d — Register the portal as an OIDCClient

warning

The OIDCClient CR name must start with client.oauth.pinniped.dev-.

apiVersion: config.supervisor.pinniped.dev/v1alpha1
kind: OIDCClient
metadata:
name: client.oauth.pinniped.dev-wxops-portal
namespace: pinniped-supervisor
spec:
allowedRedirectURIs:
- https://portal.example.com/auth/callback
allowedGrantTypes:
- authorization_code
- refresh_token
- urn:ietf:params:oauth:grant-type:token-exchange
allowedScopes:
- openid
- offline_access
- username
- groups
- pinniped:request-audience

1e — Generate the client secret

warning

Pinniped stores only a bcrypt hash — the plaintext is returned once. Copy it immediately into Vault.

cat <<EOF | kubectl create -o yaml -f -
apiVersion: clientsecret.supervisor.pinniped.dev/v1alpha1
kind: OIDCClientSecretRequest
metadata:
name: client.oauth.pinniped.dev-wxops-portal
namespace: pinniped-supervisor
spec:
generateNewSecret: true
EOF

Store the status.generatedSecret value in Vault under the portal's secret path before proceeding.

Emergency rotation (revokes all previous secrets):

cat <<EOF | kubectl create -o yaml -f -
apiVersion: clientsecret.supervisor.pinniped.dev/v1alpha1
kind: OIDCClientSecretRequest
metadata:
name: client.oauth.pinniped.dev-wxops-portal
namespace: pinniped-supervisor
spec:
generateNewSecret: true
revokeOldSecrets: true
EOF

Step 2 — Store portal secrets in Vault

The portal's environment variables are injected from a Kubernetes Secret that External Secrets Operator syncs from Vault. Create the KV entry at secret/platform/service-portal (adjust the mount path to match your VAULT_KV_MOUNT):

vault kv put secret/platform/service-portal \
SESSION_SECRET="$(openssl rand -hex 32)" \
OIDC_ISSUER_URL="https://pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local" \
OIDC_CLIENT_ID="client.oauth.pinniped.dev-wxops-portal" \
OIDC_CLIENT_SECRET="<paste generatedSecret from step 1e>" \
OIDC_REDIRECT_URI="https://portal.example.com/auth/callback" \
GITEA_URL="https://gitea.example.com" \
GITEA_TOKEN="<gitea-service-account-token>" \
GITEA_CATALOG_OWNER="platform-team" \
GITEA_CATALOG_REPO="gitops-infra" \
VAULT_ADDR="https://vault.example.com" \
VAULT_TOKEN="<vault-portal-policy-token>" \
WEBHOOK_TOKEN="$(openssl rand -hex 24)"

See Environment Variables for the full reference of every key.

The ExternalSecret that syncs this path is included in the portal Helm chart templates:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: platform-service-portal-env
namespace: platform
spec:
refreshInterval: 1m
secretStoreRef:
name: vault-platform # ClusterSecretStore pointing to your Vault
kind: ClusterSecretStore
target:
name: platform-service-portal-env
dataFrom:
- extract:
key: service-portal # → secret/platform/service-portal

Step 3 — Deploy the portal

The portal uses a common Helm chart dependency from kubewekend.xeusnguyen.xyz.

3a — Create the Chart.yaml

apiVersion: v2
name: service-portal
version: 1.0.0
dependencies:
- name: common
version: 0.3.2
repository: https://kubewekend.xeusnguyen.xyz
alias: portal
condition: portal.enabled

3b — Configure values.yaml

portal:
enabled: true
fullnameOverride: wxops-service-portal

image:
repository: gitea.xeusnguyen.xyz/platform-team/wxops-portal-v2
tag: latest # ← pin to a specific release tag in production
pullPolicy: Always
imagePullSecrets:
- name: regcred # ← pull secret for the private Gitea registry

service:
type: ClusterIP
port: 80

ingress:
enabled: true
className: traefik
annotations:
cert-manager.io/cluster-issuer: "ca-issuer"
hosts:
- host: portal.example.com
paths:
- path: /
pathType: ImplementationSpecific
tls:
- secretName: service-portal-tls
hosts:
- portal.example.com

# Environment variables pulled from the Vault-synced Secret
envFrom:
- secretRef:
name: platform-service-portal-env

# FRONTEND_URL is safe to set as a plain env var (not a secret)
env:
FRONTEND_URL: "https://portal.example.com"

resources:
requests:
memory: 256Mi
cpu: 50m
limits:
memory: 512Mi

probes:
port: http
disableProbes: [startupProbe]
livenessProbe:
httpGet:
path: /healthz
readinessProbe:
httpGet:
path: /readyz

# Role + RoleBinding: read Secrets in the portal namespace for cluster discovery
rbac:
create: true
role:
kind: Role
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
roleBinding:
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
subjects:
- kind: ServiceAccount
name: wxops-service-portal

serviceAccount:
create: true
name: wxops-service-portal

3c — Sync via ArgoCD Application

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-service-portal
namespace: argocd
spec:
project: infrastructure
source:
repoURL: https://gitea.example.com/platform-team/gitops-infra.git
targetRevision: main
path: platform/service-portal
helm:
releaseName: platform-service-portal
valueFiles: [values.yaml]
destination:
server: https://kubernetes.default.svc
namespace: platform
syncPolicy:
syncOptions: [CreateNamespace=true]

Step 4 — Register spoke clusters

Each spoke cluster requires:

  1. Pinniped Concierge installed with a JWTAuthenticator pointing to the hub Supervisor
  2. Tenant RBAC so Pinniped-issued tokens can list pods/deployments in tenant namespaces
  3. A cluster registration Secret in the hub's wxops-system namespace

4a — JWTAuthenticator on each spoke

apiVersion: authentication.concierge.pinniped.dev/v1alpha1
kind: JWTAuthenticator
metadata:
name: wxops-jwt-authenticator
spec:
issuer: https://pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local
audience: prod-east # must be unique per cluster
tls:
certificateAuthorityData: <base64-encoded Supervisor CA PEM>

4b — Tenant RBAC on each spoke

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: wxops-portal-tenant-viewer
rules:
- apiGroups: [""]
resources: ["pods", "services", "resourcequotas"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: wxops-portal-viewer
namespace: tenant-<org>
subjects:
- kind: Group
name: <org>:rocket-team # Gitea OIDC group in org:team format
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: wxops-portal-tenant-viewer
apiGroup: rbac.authorization.k8s.io

See Permissions for the full RBAC matrix.

4c — Cluster registration Secret on the hub

Create one Secret per spoke in the hub cluster's wxops-system namespace:

apiVersion: v1
kind: Secret
metadata:
name: cluster-prod-east
namespace: wxops-system
labels:
wxops.cloud/kind: cluster
annotations:
wxops.cloud/cluster-id: prod-east
wxops.cloud/cluster-name: "Production East"
wxops.cloud/jwt-authenticator-audience: prod-east
wxops.cloud/jwt-authenticator-name: wxops-jwt-authenticator
wxops.cloud/issuer-url: https://pinniped-supervisor-clusterip.pinniped-supervisor.svc.cluster.local
wxops.cloud/upstream-idp-name: "Dex OIDC Authenticator"
wxops.cloud/upstream-idp-type: oidc
stringData:
api-server: "https://api.prod-east.example.com:6443"
ca-bundle: |
-----BEGIN CERTIFICATE-----
<spoke cluster CA PEM>
-----END CERTIFICATE-----

Step 5 — Supervisor TLS CA

Supervisor certificateAction
Let's Encrypt / public CANothing — system root pool is used automatically
Internal / private CASet OIDC_CA_BUNDLE or OIDC_CA_BUNDLE_FILE on the portal
Self-signed (dev only)Set OIDC_TLS_SKIP_VERIFY=true — kubeconfig downloads will not work

Verification

After all steps complete:

# Portal pod should be Running
kubectl get pods -n platform -l app.kubernetes.io/name=wxops-service-portal

# Pinniped Supervisor FederationDomain should be Ready
kubectl get federationdomain -n pinniped-supervisor

# OIDCClient should be Ready
kubectl get oidcclient client.oauth.pinniped.dev-wxops-portal \
-n pinniped-supervisor \
-o jsonpath='{.status.conditions}' | jq .

# Portal health endpoints
curl -sk https://portal.example.com/healthz
curl -sk https://portal.example.com/readyz

Open https://portal.example.com — you should be redirected to the Pinniped / Dex login flow.


Next steps