Skip to main content

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:

Throughout, <your-domain> is the value of global.domain in your Helm overlay, and plexicus is the release namespace.

What you can skip

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

This is a hard prerequisite, not a post-install step

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
KeyRequiredPurpose
license.jwtYesThe signed licence the platform validates at startup and re-checks periodically
license-key.pemNoOnly 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
Do not list keys you do not have

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:

ValueEnforced?Rule
global.domainYesRequired, non-empty, and the placeholders plexicus.example.com, example.com and <to-fill> are rejected outright
global.schemeYesMust be http or https
global.wsSchemeYesMust be ws or wss
global.required.oauth.github.appIdYesMust be a string. Plexicus's own App id is rejected by value
global.required.oauth.github.clientIdYesPlexicus's own OAuth client id is rejected by value. Empty is valid and means "not configured"
global.required.ai.openAiDeploymentNameFreeYesRequired. Placeholder values are rejected
global.required.ai.openAiDeploymentSweYesRequired. Placeholder values are rejected
The AI model ids are required — set them or the render fails

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:

deliveryModesmtp.serverStateBehaviour
"smtp" (default)setconfiguredMail is sent normally
"smtp" (default)emptyaccidentally unconfiguredEvery email-dependent call fails with 503 — register, invite, resend-verification, password reset
"disabled"ignoreddeclared disabledNothing 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"
An unrecognised value silently means smtp

deliveryMode 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"

Self-registration is never auto-verified, in any delivery mode

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.

Every user after the first

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

The scanning worker does not yet honour EMAIL_DELIVERY_MODE

The 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

  1. Sign in as the admin from section 3.
  2. Go to Settings → Integrations → GitHub. With no App configured, a panel offers Create GitHub App automatically.
  3. Click it. Your browser POSTs a prepared manifest to https://github.com/settings/apps/new.
  4. Review what GitHub shows you and confirm. GitHub creates the App and redirects back to /settings/github-app-callback on your own domain with a single-use code.
  5. 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.
  6. 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

PropertyValue
VisibilityPrivate
Permissionscontents: read, metadata: read, pull_requests: write, issues: write, statuses: write
Subscribed eventspush, pull_request, pull_request_review
Callback URLhttps://<your-domain>/api/callback/github
Setup / redirect URLhttps://<your-domain>/settings/github-app-callback
WebhooksIncluded only when your domain is publicly resolvable
Three limits worth knowing before you choose this path
  • 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 .local hostname 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, appPrivateKey and 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

Quote appId

GITHUB_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:

ProviderChart keyPurpose
GitHubglobal.required.oauth.githubRepository connector and "Sign in with GitHub"
GitLab (gitlab.com)global.required.oauth.gitlabRepository connector
Bitbucket Cloudglobal.required.oauth.bitbucketRepository connector
Googleglobal.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.

ProviderRedirect 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.

Byte-identical, or the exchange fails

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 GitLabUser 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>"
SecretKey
plexicus-fastapiGITLAB_OAUTH_CLIENT_SECRET
plexicus-frontendNUXT_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.

Self-managed GitLab is a different path

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

Bitbucket Server / Data Center is not supported

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 BitbucketWorkspace 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; Repositories Read; Pull requests Write; Issues Write

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>"
SecretKey
plexicus-fastapiBITBUCKET_OAUTH_CLIENT_SECRET
plexicus-frontendNUXT_BITBUCKET_CLOUD_SECRET

VerifyConnectors → 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 much

GET /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-secret the chart references;
  • global.imagePullSecrets does 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 mentionsCauseFix
global.domainLeft at a placeholder, or emptySet your real domain
global.required.oauth.github.appIdSet to a number rather than a string, or to Plexicus's own App idQuote it, or set ""
global.required.oauth.github.clientIdSet to Plexicus's own OAuth client idUse your own App's client id, or ""
global.required.ai.openAiDeployment*Missing, or left at a placeholderSet both to a real model id at your endpoint
global.scheme / global.wsSchemeA value outside the allowed pairhttp/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 http origins, with localhost as the only exception the browser makes. If you set global.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_ID with 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