On-Premises Walkthrough
This page is the ordered path from an empty Kubernetes cluster to a working Plexicus deployment with at least one source-control provider connected. It assumes nothing is configured yet and stops at your first completed scan.
It deliberately overlaps with the reference pages rather than replacing them. Where a topic has a fuller treatment elsewhere, this page gives the step and links out:
- Self-Hosted Installation — the complete Helm reference, ArgoCD, upgrades, sizing
- SCM Connection Reference — every provider's flow, scopes and callback URLs
- Configuration Reference — which integrations are documented and which are Plexicus-internal
- Evaluator Installation — the one-command VM install, if you only want to try it
Throughout, <your-domain> is the value of global.domain in your Helm
overlay, and plexicus is the release namespace.
No source-control provider is required to install or to run a first scan. The Sandbox onboarding flow scans a pre-configured repository through a synthetic connector. Sections 4 and 5 are only needed once you want to scan your own repositories.
1. Prerequisites and the licence
1.1 Cluster and tooling
- A Kubernetes cluster you can reach with
kubectl, and Helm 3. - An ingress controller and a DNS name you control, pointed at it.
- The five infrastructure services — MongoDB, Redis, Temporal, object storage and PostgreSQL (for Temporal). They ship as opt-in bundled subcharts, or you can point the chart at instances you already run. See Self-Hosted Installation.
1.2 The licence Secret — do this before helm install
The plexicus-license Secret must exist before you install the chart.
There is no unlicensed mode and no degraded mode. The fastapi and worker
Deployments both mount it as a volume with optional: false, so if it is
missing the kubelet refuses to start those containers at all: the pods sit in
ContainerCreating indefinitely, emit a FailedMount event, and write no
logs — there is no application output to debug, because no application ever
started.
Plexicus issues your licence file. Request it from engineering@plexicus.ai if you do not have one; it arrives alongside your container-registry credentials.
Create the namespace and the Secret:
kubectl create namespace plexicus
kubectl -n plexicus create secret generic plexicus-license \
--from-file=license.jwt=/path/to/license.jwt
| Key | Required | Purpose |
|---|---|---|
license.jwt | Yes | The signed licence the platform validates at startup and re-checks periodically |
license-key.pem | No | Only used by Plexicus's own licence-minting surface. Include it only if it was delivered to you |
Add the optional key to the same command if you received one:
kubectl -n plexicus create secret generic plexicus-license \
--from-file=license.jwt=/path/to/license.jwt \
--from-file=license-key.pem=/path/to/license-key.pem
The chart mounts whatever keys the Secret provides rather than demanding a
fixed list. Creating the Secret with only license.jwt is correct and
supported.
1.3 The registry credential
Plexicus service images are pulled from a private registry, so the cluster needs
a pull secret in the same namespace. The chart's default
global.imagePullSecrets entry is named gar-secret — if you name yours
something else, override that value to match.
kubectl -n plexicus create secret docker-registry gar-secret \
--docker-server=europe-west3-docker.pkg.dev \
--docker-username=_json_key \
--docker-password="$(cat /path/to/registry-key.json)" \
--docker-email=engineering@plexicus.ai
The same credential authenticates the helm pull of the chart itself. If you
are mirroring images into your own registry for an air-gapped install, see the
Air-Gapped guide instead.
2. Install the chart
2.1 The values the schema actually enforces
The chart ships a JSON Schema that runs at helm install / helm template
time, so a misconfigured overlay fails before it reaches the cluster. What it
enforces is narrower than you might expect, so it is worth knowing exactly:
| Value | Enforced? | Rule |
|---|---|---|
global.domain | Yes | Required, non-empty, and the placeholders plexicus.example.com, example.com and <to-fill> are rejected outright |
global.scheme | Yes | Must be http or https |
global.wsScheme | Yes | Must be ws or wss |
global.required.oauth.github.appId | Yes | Must be a string. Plexicus's own App id is rejected by value |
global.required.oauth.github.clientId | Yes | Plexicus's own OAuth client id is rejected by value. Empty is valid and means "not configured" |
global.required.ai.openAiDeploymentNameFree | Yes | Required. Placeholder values are rejected |
global.required.ai.openAiDeploymentSwe | Yes | Required. Placeholder values are rejected |
global.required.ai.openAiDeploymentNameFree and openAiDeploymentSwe are both
required, and the schema rejects the REPLACE_WITH_DEEPINFRA_MODEL_ID
placeholder and the <to-fill> sentinel by pattern.
The guard exists because the failure it replaces was invisible. A placeholder
model id renders into AI_VALIDATION_MODEL, AI_REMEDIATION_MODEL and
AI_CODEX_MODEL, and the platform only checks that the value is non-empty — so
Settings → AI reported the tenant as configured while every call failed at
the provider. Now the install refuses to render instead, naming the path.
Set both to a real model id at whichever endpoint you configured, and confirm what rendered in section 6.
2.2 A minimal overlay
# my-values.yaml
global:
domain: plexicus.yourdomain.com # REQUIRED — placeholders are rejected
scheme: "https"
wsScheme: "wss"
ingressClassName: "nginx"
certManager:
enabled: true
clusterIssuer: "letsencrypt-prod"
imagePullSecrets:
- name: gar-secret
required:
ai:
# REQUIRED — the chart refuses to render without these.
openAiDeploymentNameFree: "deepseek-ai/DeepSeek-V3"
openAiDeploymentSwe: "zai-org/GLM-5.2"
Everything else has a working default, resolves from global.domain, or comes
from the per-service existingSecret references. The full key catalogue and
the Secret-creation recipe are in
Self-Hosted Installation.
2.3 Install
helm upgrade --install plexicus \
oci://europe-west3-docker.pkg.dev/plexicus-registry/charts/plexicus \
--version <chart-version> \
--namespace plexicus \
--values my-values.yaml \
--timeout 20m
A schema violation fails here, before anything is created, and names the offending path in the error.
3. First login, and onboarding without a mail server
This is the step that surprises people, so read it before you register anyone.
3.1 Choose a delivery mode deliberately
global.required.smtp.deliveryMode renders as EMAIL_DELIVERY_MODE and has
exactly two accepted values, producing three distinguishable states:
deliveryMode | smtp.server | State | Behaviour |
|---|---|---|---|
"smtp" (default) | set | configured | Mail is sent normally |
"smtp" (default) | empty | accidentally unconfigured | Every email-dependent call fails with 503 — register, invite, resend-verification, password reset |
"disabled" | ignored | declared disabled | Nothing is sent, responses carry email_sent: false, onboarding becomes admin-mediated |
The distinction between the last two is the whole point. An empty SMTP host is
legitimate for an air-gapped deployment — but only when you say so. Previously
an install that merely forgot to configure SMTP returned 200 and told the user
to check an inbox nothing was ever sent to; the 503 replaces that silence.
To declare that a deployment runs without email:
global:
required:
smtp:
deliveryMode: "disabled"
smtpdeliveryMode is not a validated enum — the schema does not constrain it, and
the code falls back to smtp for anything it does not recognise, deliberately,
so a typo lands in the loud branch rather than quietly disabling email. The
consequence is that "disbaled" puts you in the 503 row with no complaint
from Helm. If register starts returning 503 unexpectedly, check the rendered
value first.
3.2 disabled does not mean "skip verification"
It is tempting to read deliveryMode: "disabled" as "verify accounts
automatically". It is not, and must not be relied on as such.
POST /registrations writes is_verified: false in every delivery mode.
The reason is deliberate: registration is a public, unauthenticated endpoint, the backend has no captcha check of its own, and each registration mints a fresh tenant carrying a trial with AI credits. Verifying there would turn "we have no mail server" into open, billable signup for anyone who can reach the host.
Redeeming a verification token is the only thing anywhere that sets
is_verified: true.
What disabled gives you instead is an admin-mediated path: an
already-authenticated admin invites a user, and the invite response itself
carries the verification link to hand over out of band. That link is returned
only to the authenticated admin who created the invitation, and only when the
mode was explicitly declared — merely forgetting to configure SMTP never
discloses one.
3.3 The first admin
The invite path presupposes an admin, so it cannot create the first one. On a cluster with no mail server, bootstrap account one directly.
Register through the API:
curl -s -X POST -H "Content-Type: application/json" \
-d '{
"email":"admin@your-domain.example",
"password":"ChangeMeNow1!",
"confirm_password":"ChangeMeNow1!"
}' \
https://api.<your-domain>/registrations
The password must contain at least one uppercase letter, one lowercase letter,
one digit and one special character. Under disabled the response is 200
with email_sent: false and a message telling the user to ask an administrator
to activate the account. A 503 here means you are in the
accidentally-unconfigured state — go back to
3.1.
Then flip the flag in MongoDB, for this account only:
kubectl -n plexicus exec deploy/mongodb -- mongosh \
"mongodb://root:<mongo-root-password>@localhost:27017/plexicus?authSource=admin" \
--quiet --eval '
db.Users.updateOne(
{ email: "admin@your-domain.example" },
{ $set: { is_verified: true, role: "admin" } }
)
'
The collection is Users, with a capital U.
Do not repeat this for subsequent users. Invite them from inside the platform —
under disabled the invite response hands the inviting admin a verification
link to pass on, which keeps the audit trail intact and does not require
database access.
3.4 Worker-side caveat
EMAIL_DELIVERY_MODEThe delivery-mode gate described above governs the API service. The chart also
renders EMAIL_DELIVERY_MODE into the worker's environment, but at the time of
writing the worker's own mail path does not consult it — it attempts delivery
directly, which on a deployment with no SMTP host reproduces exactly the
dial-localhost-and-swallow behaviour the gate was introduced to remove.
Target behaviour, once the fix lands: the worker uses the same three-state gate
as the API, so a declared-disabled deployment attempts no delivery from any
process, and an accidentally-unconfigured one fails loudly from both.
Impact today is limited to worker-originated notification mail (scan completion and similar). It does not affect registration, invitation, password reset, or any of the onboarding flow above, all of which are API-side and already gated. Treat worker-sent mail as best-effort until this note is removed.
4. GitHub — let the product create the App
Since the first-boot setup wizard was retired, GitHub App creation lives inside the product. This is the shortest correct path and the one to prefer.
4.1 Run the flow
- Sign in as the admin from section 3.
- Go to Settings → Integrations → GitHub. With no App configured, a panel offers Create GitHub App automatically.
- Click it. Your browser POSTs a prepared manifest to
https://github.com/settings/apps/new. - Review what GitHub shows you and confirm. GitHub creates the App and
redirects back to
/settings/github-app-callbackon your own domain with a single-use code. - Plexicus exchanges the code, stores the App's credentials, and puts them into use immediately. The scanning worker picks them up on its own refresh loop — nothing needs restarting.
- Install the App on the account or organisation that owns your repositories. The callback page links you to its installation URL.
The button appears only when the deployment has no working App and your account holds the backoffice-settings admin permission.
4.2 What the generated App contains
| Property | Value |
|---|---|
| Visibility | Private |
| Permissions | contents: read, metadata: read, pull_requests: write, issues: write, statuses: write |
| Subscribed events | push, pull_request, pull_request_review |
| Callback URL | https://<your-domain>/api/callback/github |
| Setup / redirect URL | https://<your-domain>/settings/github-app-callback |
| Webhooks | Included only when your domain is publicly resolvable |
- One callback URL. The generated App registers only the sign-in and
connector callback. If you also need the standalone vulnerability-tool flow
and the App-installation hand-off, add the second,
api.-prefixed callback URL by hand afterwards — see the SCM Connection Reference. - Webhooks depend on reachability. Against a private or
.localhostname the manifest omits webhook configuration entirely rather than handing GitHub an address it can never reach. The callback page tells you when this happened. Scans still work; only push-triggered rescans are unavailable. - No in-product replacement. The creation flow is offered only while the deployment has no stored App. Changing App later is a manual operation.
4.3 Precedence against the chart values
A stored App wins over the environment, consistently across all three processes
that need it — the API, the scanning worker, and the Nuxt server that performs
the browser login's token exchange. That uniformity is the point: a split
ordering would send the browser to one App's authorize page and exchange the
code as another, and GitHub would answer with a bare
incorrect_client_credentials.
Two consequences:
- If you use the creation flow, leave
appId,appInstallationUrl,appPrivateKeyand the GitHub client credentials unset. They are not consulted once an App is stored. - A deployment configured purely by chart values is undisturbed. With no stored App the lookup finds nothing and the environment pair is used unchanged.
Only a complete stored pair displaces the environment; a half-stored App never does.
4.4 If you register the App by hand instead
appIdGITHUB_APP_ID must be a quoted string in YAML. Helm round-trips values through
JSON, where numbers become floats, and a 7-digit number is then printed in
scientific notation — so an unquoted appId: 1234567 reaches the container as
1.234567e+06, which no GitHub API accepts. Nothing fails at render time;
App-authenticated calls simply stop working.
appId: "1234567" # correct
appId: 1234567 # silently broken
Do not write appId: 0 to mean "no App" either — the schema types it as a
string, so a bare 0 fails validation. The value that means "no App" is "".
Full walkthrough, permissions table and both callback URLs: SCM Connection Reference → GitHub.
5. The other providers
5.1 Which providers have chart-level credentials
Only four, and only for their SaaS instances:
| Provider | Chart key | Purpose |
|---|---|---|
| GitHub | global.required.oauth.github | Repository connector and "Sign in with GitHub" |
| GitLab (gitlab.com) | global.required.oauth.gitlab | Repository connector |
| Bitbucket Cloud | global.required.oauth.bitbucket | Repository connector |
global.required.oauth.google | "Sign in with Google" only — not an SCM |
Everything else — GitHub Enterprise Server, self-managed GitLab, Gitea, Forgejo, Azure DevOps and TFVC — carries its credentials per connection, in the UI, and needs nothing in your Helm overlay. Gitea and Forgejo are personal-access-token connectors and do not use OAuth at all.
5.2 Redirect URIs are derived — do not invent them
The chart derives every redirect URI from global.domain. Register exactly
these with the provider; you do not set them in your overlay.
| Provider | Redirect URI to register |
|---|---|
| GitHub | <scheme>://<domain>/api/callback/github |
| GitLab | <scheme>://<domain>/api/callback/gitlab |
| Bitbucket Cloud | <scheme>://<domain>/api/callback/bitbucket_cloud |
All three land on the frontend origin, with no api. prefix. Note the
bitbucket_cloud slug with an underscore — it is the frontend route name, not
the backend's provider key, and the two differ.
OAuth requires the redirect_uri sent at the token exchange to be identical to
the one sent at /authorize. Same scheme, same host, no trailing slash, no
query string. A mismatch surfaces as redirect_uri_mismatch (GitHub) or
invalid_redirect_uri (GitLab, Bitbucket) immediately after the user clicks
Authorize.
5.3 GitLab (gitlab.com)
On GitLab — User settings → Applications → New application, or Admin Area → Applications for an instance-wide registration:
- Redirect URI:
https://<your-domain>/api/callback/gitlab - Confidential: keep checked
- Scopes — grant all eight:
api,read_api,read_user,read_repository,write_repository,openid,profile,email. Granting fewer lets authorization succeed and the later repository calls fail.
In Plexicus — the Application ID is a public value and goes in the overlay; the Secret goes in Kubernetes Secrets:
global:
required:
oauth:
gitlab:
clientId: "<application-id>"
| Secret | Key |
|---|---|
plexicus-fastapi | GITLAB_OAUTH_CLIENT_SECRET |
plexicus-frontend | NUXT_GITLAB_SECRET_KEY |
Verify — open Connectors → SCM → GitLab and connect. You should be redirected to GitLab, consent, and return to the connector showing Connected, with your repositories listed.
A self-managed instance does not use these chart values. Each connection carries the client credentials of an application registered on your own GitLab, entered in the UI. Because the redirect URI is the same for both, one deployment can serve a gitlab.com connection and any number of self-managed ones at once. See GitLab → Self-Hosted.
5.4 Bitbucket Cloud
Plexicus supports Bitbucket Cloud only. There is no Bitbucket Server
provider in the platform, and the connector has no instance-URL field — every
call is hard-coded to api.bitbucket.org. If you run Data Center, mirror the
repositories to a supported forge or contact
engineering@plexicus.ai.
On Bitbucket — Workspace settings → OAuth consumers → Add consumer:
- Callback URL:
https://<your-domain>/api/callback/bitbucket_cloud - URL (homepage):
https://<your-domain> - This is a private consumer: keep checked
- Permissions: Account
Read; RepositoriesRead; Pull requestsWrite; IssuesWrite
Plexicus sends no scope parameter to Bitbucket — the consumer's permission
checkboxes are what the token carries, so set them before you connect.
In Plexicus — note the key is named key, not clientId:
global:
required:
oauth:
bitbucket:
key: "<consumer-key>"
| Secret | Key |
|---|---|
plexicus-fastapi | BITBUCKET_OAUTH_CLIENT_SECRET |
plexicus-frontend | NUXT_BITBUCKET_CLOUD_SECRET |
Verify — Connectors → SCM → Bitbucket, connect, and confirm your workspace repositories appear.
5.5 Token-based providers
Gitea, Forgejo, Azure DevOps and TFVC need no chart values, no Secret keys and no callback URL. Generate a token on the provider, then enter the instance URL and token under Connectors → SCM. Plexicus validates the token against your instance before saving it. Per-provider detail: Gitea, Forgejo, Azure DevOps.
6. Verify the install
Work through these in order. Each one rules out a different class of failure.
6.1 Pods
kubectl -n plexicus get pods
Every Plexicus pod should be Running and Ready. Anything in
ContainerCreating, ImagePullBackOff or CrashLoopBackOff is covered in
section 7.
6.2 The licence
The API exposes the current licence state. It requires a bearer token, so log in first and use that token:
curl -s https://api.<your-domain>/system/license \
-H "Authorization: Bearer <your-token>"
{
"state": "valid",
"customer": "…",
"plan": "…",
"features": ["…"],
"expires_at": 1234567890,
"grace_days_remaining": 0,
"license_id": "…",
"deployment_type": "…"
}
state is lowercase and is one of valid, grace, expired or invalid.
valid is what you want. A 503 from this endpoint means the licence
verifier never initialised.
The same information appears in the API pod's startup log, which is useful when you cannot yet log in:
kubectl -n plexicus logs deploy/fastapi | grep "License OK"
# License OK — customer='…' plan=… state=valid
/health does not prove very muchGET /health returns a static {"status": "ok"}. It is a liveness probe: it
does not check the database, the licence, or any dependency. A 200 from it
tells you the process is up and nothing more. Use the licence endpoint above
for a real readiness signal.
6.3 The AI model ids actually rendered
The schema catches a placeholder in your overlay, but it cannot see a value injected some other way. Confirm what the pod actually received:
kubectl -n plexicus get deploy fastapi -o \
jsonpath='{range .spec.template.spec.containers[0].env[?(@.name=="AI_VALIDATION_MODEL")]}{.value}{"\n"}{end}'
If this prints REPLACE_WITH_DEEPINFRA_MODEL_ID, fix your overlay before going
further. Repeat for AI_REMEDIATION_MODEL on the worker Deployment.
6.4 A first scan
Sign in at https://<your-domain>, accept the agreement, and choose the
Sandbox onboarding path — it scans a pre-configured vulnerable repository
and needs no SCM connection. A live scan terminal streams per-tool events, and
the run lands you on the Dashboard with findings.
A completed Sandbox scan exercises the whole chain: frontend, ingress, API, database, object storage, the scanning worker and the AI channels. If it succeeds, the install is sound and any remaining problem is provider-side.
7. Troubleshooting
Pods stuck in ContainerCreating, with a FailedMount event
kubectl -n plexicus describe pod <pod> | tail -20
# MountVolume.SetUp failed for volume "plexicus-license" :
# secret "plexicus-license" not found
The licence Secret does not exist, or is in the wrong namespace. Because the
volume is mounted with optional: false, the container never starts and there
are no application logs to inspect. Create it as in
1.2; the pods recover on their own once
it exists — no rollout needed.
ImagePullBackOff / ErrImagePull
kubectl -n plexicus describe pod <pod> | grep -A3 Events
Usually one of:
- the pull secret is missing from the namespace, or is named something other
than the
gar-secretthe chart references; global.imagePullSecretsdoes not list the name you actually created;- the credential is expired or was created with the wrong
--docker-server.
kubectl -n plexicus get secret gar-secret -o jsonpath='{.type}'
# kubernetes.io/dockerconfigjson
worker CrashLoops with "WORKER_CONTROL_PLANE_SECRET must be set"
Not a licence problem. The chart sets ENVIRONMENT=production, which makes
WORKER_CONTROL_PLANE_SECRET mandatory at worker startup. The API presents the
same value as X-Shared-Secret when it calls the worker's control plane, so it
is a shared value — it must be identical in the fastapi and worker
Secrets. A mismatch is as bad as an absence: the worker boots and then rejects
every control-plane call.
EXPORTER_INTERNAL_TOKEN behaves the same way between the worker and
exporter Secrets, when the exporter is enabled. Both are in the key catalogue
in Self-Hosted Installation.
CrashLoopBackOff with a clean container start
The licence Secret mounted correctly but the licence itself is invalid or expired. Both services exit deliberately at startup when validation fails, which Kubernetes reports as a crash loop rather than a config error.
kubectl -n plexicus logs deploy/fastapi | tail -30
Request a renewed license.jwt from
engineering@plexicus.ai.
The install fails before anything is created
A schema rejection. The error names the offending path. The ones you are most likely to hit:
| Message mentions | Cause | Fix |
|---|---|---|
global.domain | Left at a placeholder, or empty | Set your real domain |
global.required.oauth.github.appId | Set to a number rather than a string, or to Plexicus's own App id | Quote it, or set "" |
global.required.oauth.github.clientId | Set to Plexicus's own OAuth client id | Use your own App's client id, or "" |
global.required.ai.openAiDeployment* | Missing, or left at a placeholder | Set both to a real model id at your endpoint |
global.scheme / global.wsScheme | A value outside the allowed pair | http/https, ws/wss |
Registration or invitation returns 503
Outbound email is neither configured nor declared absent. Set
global.required.smtp.server, or declare
global.required.smtp.deliveryMode: "disabled". See
3.1. Check the rendered value for a typo
before anything else — an unrecognised mode silently means smtp.
A user cannot log in: "not yet verified"
Expected after a self-registration, in every delivery mode. Either the user redeems a verification link, or an admin invites them and hands over the link from the invite response. Do not reach for the database for anyone but the first admin — see 3.3.
Passkey registration fails silently
Nothing appears in any log because the request never leaves the browser. The
chart derives the WebAuthn relying party from global.domain — the RP ID is the
bare host, the origin is <scheme>://<host> — and the browser itself rejects an
RP ID that is not equal to, or a registrable domain suffix of, the page's own
domain.
Two things to check:
- A secure context. WebAuthn is unavailable on plain
httporigins, withlocalhostas the only exception the browser makes. If you setglobal.scheme: "http", passkeys will not work on any real hostname. - A shared parent domain. If you serve the interface from several
hostnames and want one passkey across them, override
WEBAUTHN_RP_IDwith the parent domain. The chart cannot derive that safely — picking a parent correctly requires the Public Suffix List, and a wrong guess produces an RP ID browsers reject outright.
Changing global.domain after passkeys are registered invalidates them: a
credential is bound to the RP ID it was created under.
redirect_uri_mismatch after clicking Authorize
The callback registered with the provider is not what Plexicus sent. Compare
character by character against
5.2 — the commonest cause is an api.
prefix that should not be there, and the second commonest is bitbucket-cloud
written with a hyphen instead of the underscore the route actually uses.
Next steps
- Self-Hosted Installation — ArgoCD, upgrades, sizing, the full key catalogue
- SCM Connection Reference — per-provider scopes, both GitHub callback URLs, webhooks
- Configuration Reference — what is documented for self-hosted and what is not
- Backup and Restore — before your first upgrade
- Air-Gapped — image mirroring and default-deny networking