Skip to main content

Install

Before you start

  • Prepare the artifacts complete
  • $KIT, $B, $REGISTRY, $REGISTRY_USER, $REGISTRY_TOKEN are set in your shell
  • $G0S_DOWNLOAD_API_KEY is set. Step 2 writes it into a secret, and an unset variable produces an empty one that fails silently
  • You are logged in to the registry

1. Check the cluster

$KIT/scripts/preflight.sh

This checks the prerequisites the charts assume but do not create: a reachable cluster, a StorageClass, and the namespaces and secrets created in step 2. On a fresh cluster it reports the namespaces and secrets as missing, which is expected at this point.

Pass a StorageClass name to pin it instead of accepting the default:

$KIT/scripts/preflight.sh gen0sec-system gen0sec my-block-storage

2. Create the namespaces and the two bootstrap secrets

for ns in gen0sec-system gen0sec; do
kubectl create namespace $ns --dry-run=client -o yaml | kubectl apply -f -
done

Written to converge, so re-running step 2 after a failure further down is safe rather than a wall of AlreadyExists.

The image pull secret

Both namespaces need it. The application runs in gen0sec; the operators, object store and cache run in gen0sec-system.

for ns in gen0sec-system gen0sec; do
kubectl -n $ns create secret docker-registry gen0sec-registry \
--docker-server=$REGISTRY \
--docker-username=$REGISTRY_USER \
--docker-password=$REGISTRY_TOKEN \
--dry-run=client -o yaml | kubectl apply -f -
done

The object store credentials

Run this once, and only once

Unlike the rest of step 2 this does not converge. It mints new random keys every time it runs, and the object store's existing data was written with the old ones — recreating the secret on a second pass strands that data behind credentials nothing holds any more. The guard below stops before that happens.

kubectl -n gen0sec-system get secret gen0sec-object-store >/dev/null 2>&1 \
&& echo "already exists — skip to step 3" \
|| echo "not present — run the block below"
umask 077
S=$(mktemp -d)
openssl rand -hex 16 | tr -d '\n' > "$S/RUSTFS_ACCESS_KEY"
openssl rand -hex 32 | tr -d '\n' > "$S/RUSTFS_SECRET_KEY"

kubectl -n gen0sec-system create secret generic gen0sec-object-store --from-file="$S"

Save both files somewhere safe before you delete them. Nothing regenerates these keys, and the data in the object store is written with them.

mkdir -p ~/gen0sec-object-store-keys
cp "$S"/RUSTFS_* ~/gen0sec-object-store-keys/ # or load them into your secret manager
rm -rf "$S"

Step 5 reads them back from that directory.

Your Gen0Sec API key

The relay presents this key to Gen0Sec when it fetches threat intelligence, GeoIP, models and IDS rules. The chart expects this exact secret name, so the relay pod will not start without it. See Credentials from gen0sec.

# Refuses to run rather than creating an empty secret. An empty key is accepted
# by Kubernetes and by the chart, and only surfaces later as every relay fetch
# returning 401 across the whole fleet — with nothing in the install log.
: "${G0S_DOWNLOAD_API_KEY:?not set — see Prepare the artifacts, step 0}"

kubectl -n gen0sec create secret generic gen0sec-download-proxy \
--from-literal=DOWNLOAD_PROXY_API_KEY=$G0S_DOWNLOAD_API_KEY

3. Install infra

helm install g0s-infra $B/charts/gen0sec-infra-*.tgz \
-n gen0sec-system \
-f $B/values/gen0sec-infra-values-onprem.yaml

This installs three operators (Postgres, Kafka, ingress), the object store and the shared cache.

Wait for all five

The next step creates resources these operators have to reconcile. Do not continue until every one is available.

kubectl -n gen0sec-system rollout status deploy/g0s-infra-postgres-operator
kubectl -n gen0sec-system rollout status deploy/strimzi-cluster-operator
kubectl -n gen0sec-system rollout status deploy/g0s-infra-synapse-operator
kubectl -n gen0sec-system rollout status deploy/dragonfly
kubectl -n gen0sec-system rollout status \
"$(kubectl -n gen0sec-system get sts,deploy -o name | grep -m1 rustfs)"

The output for each is similar to this:

deployment "g0s-infra-postgres-operator" successfully rolled out
Why the last one resolves its own kind

The object store is a StatefulSet in the production configuration and a Deployment in the single-node configuration. Resolving the kind means the same command works in both.

4. Install data

helm install g0s-data $B/charts/gen0sec-data-*.tgz \
-n gen0sec-system \
-f $B/values/gen0sec-data-values-onprem.yaml

This creates the Postgres cluster, the Kafka cluster, its topics, and the database roles.

Wait for both clusters

Both take several minutes. Nothing after this works until Postgres reports Running.

kubectl -n gen0sec-system wait --for=condition=Ready kafka/core --timeout=15m
kubectl -n gen0sec-system wait \
--for=jsonpath='{.status.PostgresClusterStatus}'=Running postgresql/core --timeout=15m

5. Create the object store bucket

The object store starts empty and nothing creates the bucket for you. Create it now, before the platform install. Services that read or write the object store fail until it exists.

This step is required on every install. The two datasets your cluster produces about itself, identity and policy-edges, live here and are never sent anywhere. See Network and connectivity.

Run it as a throwaway pod in the cluster. The pod reads the keys straight from the gen0sec-object-store secret, so they never reach your shell and never touch your local AWS configuration.

kubectl -n gen0sec-system run s3-init --rm -i --restart=Never \
--image=amazon/aws-cli:2.15.0 \
--overrides='{"spec":{"containers":[{"name":"s3-init","image":"amazon/aws-cli:2.15.0",
"env":[{"name":"AWS_ACCESS_KEY_ID","valueFrom":{"secretKeyRef":{"name":"gen0sec-object-store","key":"RUSTFS_ACCESS_KEY"}}},
{"name":"AWS_SECRET_ACCESS_KEY","valueFrom":{"secretKeyRef":{"name":"gen0sec-object-store","key":"RUSTFS_SECRET_KEY"}}},
{"name":"AWS_EC2_METADATA_DISABLED","value":"true"},
{"name":"AWS_REGION","value":"us-east-1"}],
"args":["--endpoint-url","http://rustfs-svc.gen0sec-system.svc.cluster.local:9000","s3","mb","s3://platform-data"]}]}}'

Expect make_bucket: platform-data. BucketAlreadyOwnedByYou is also a pass, so this step converges: running it twice is harmless.

Offline installs: mirror this one image first

amazon/aws-cli is not in the offline bundle. The bundle carries only what release-manifest.yaml pins, and its SHA256SUMS covers the whole tree — the bundler refuses a package containing anything the checksums do not list — so it cannot be added after the fact.

Mirror it alongside the bundle, then point the pod at your copy:

skopeo copy docker://amazon/aws-cli:2.15.0 docker://$REGISTRY/amazon/aws-cli:2.15.0

Substitute $REGISTRY/amazon/aws-cli:2.15.0 for amazon/aws-cli:2.15.0 in both the --image flag and the image field of the overrides above. They must match: the --image flag sets the pod's image, and the override object sets the container's, and Kubernetes uses the latter.

Verify by running the same command with "s3","ls" in place of "s3","mb","s3://platform-data". The bucket should be listed:

2026-09-01 14:02:11 platform-data

An empty result means the bucket was not created, and the services in step 7 will fail with 500 NoSuchBucket. Do not continue.

If you changed the bucket name

platform-data is the value of global.commonEnv.S3_BUCKET. If you overrode it, use your name instead.

Do not export the object-store keys into your shell

The in-cluster method above is recommended because the credentials never enter your shell. That matters most on managed Kubernetes.

On EKS, exporting them breaks kubectl. The kubeconfig authenticates through an aws eks get-token exec plugin that reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY — the same two variables. Setting them to the object-store keys makes every later command in that shell fail with:

error: You must be logged in to the server (Unauthorized)

The error names kubectl, not the variables, which makes it hard to attribute. The in-cluster method avoids it entirely. If you use the workstation method below, scope the credentials to the one command with env ... rather than exporting them.

If the object store returns 401, the keys are not the ones this install generated. Keys left over from a previous install of the same cluster do this, and so does a stale copy in ~/gen0sec-object-store-keys/. Credentials are genuinely validated: wrong keys return InvalidAccessKeyId, and none at all returns 403.

Workstation method, for reference

This needs no S3 client image in the cluster, which is why it was preferred: on the offline bundle method an in-cluster image has to be mirrored first, so the bucket comes to depend on a step that exists only to create the bucket. Use it if that trade-off suits you — but read the caution above first, because exporting the object-store keys will break kubectl on EKS.

5a. Open a port forward

In a second terminal:

kubectl -n gen0sec-system port-forward svc/rustfs-svc 9000:9000

Leave it running. The output is similar to this:

Forwarding from 127.0.0.1:9000 -> 9000

5b. Create the bucket

Back in your first terminal, use the two keys you generated in step 2. Scope them to the one command with env rather than exporting them, so they cannot clobber the variables your kubeconfig's exec plugin reads:

env AWS_ACCESS_KEY_ID="$(cat ~/gen0sec-object-store-keys/RUSTFS_ACCESS_KEY)" \
AWS_SECRET_ACCESS_KEY="$(cat ~/gen0sec-object-store-keys/RUSTFS_SECRET_KEY)" \
AWS_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true \
aws --endpoint-url http://localhost:9000 s3 mb s3://platform-data

The output is similar to this:

make_bucket: platform-data

BucketAlreadyOwnedByYou is also a pass.

With the MinIO client instead:

mc alias set g0s http://localhost:9000 \
"$(cat ~/gen0sec-object-store-keys/RUSTFS_ACCESS_KEY)" \
"$(cat ~/gen0sec-object-store-keys/RUSTFS_SECRET_KEY)"
mc mb g0s/platform-data
mc ls g0s

5c. Verify it exists

env AWS_ACCESS_KEY_ID="$(cat ~/gen0sec-object-store-keys/RUSTFS_ACCESS_KEY)" \
AWS_SECRET_ACCESS_KEY="$(cat ~/gen0sec-object-store-keys/RUSTFS_SECRET_KEY)" \
AWS_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true \
aws --endpoint-url http://localhost:9000 s3 ls

The output is similar to this:

2026-09-01 14:02:11 platform-data

An empty result means the bucket was not created, and the services in step 7 will fail with 500 NoSuchBucket. Do not continue.

Then stop the port forward with Ctrl-C in the second terminal.

6. Create the application secrets

$KIT/scripts/make-db-secrets.sh $B/values/gen0sec-platform-values-onprem.yaml

This composes around twenty secrets from the passwords the Postgres operator generated in step 4. That is why it cannot run earlier.

Each service gets its own database role through the connection pooler. The migration job gets a superuser connection direct to the primary, because it runs CREATE EXTENSION.

In production, treat this script's output as a specification

make-db-secrets.sh is fine for evaluation. For production, have your secret manager produce secrets of the same shape. Every name and key is listed in Secrets.

7. Install the platform

helm install g0s $B/charts/gen0sec-platform-*.tgz \
-n gen0sec \
-f $B/values/gen0sec-platform-values-onprem.yaml \
--set global.imageTag=$VERSION \
--timeout 20m
Nothing to set for the relay

The chart already points at gen0sec-download-proxy by default. You created that secret in step 2, and if it is missing the relay pod fails to start with CreateContainerConfigError rather than coming up half-configured.

Schema migrations run first, as a pre-install hook. If they fail, Helm aborts and nothing is deployed. That is deliberate: a partially migrated database is worse than no install.

Check the migration

kubectl -n gen0sec get job g0s-db-migrate

The output is similar to this:

Error from server (NotFound): jobs.batch "g0s-db-migrate" not found

NotFound is the success case. The job is a Helm hook with hook-delete-policy: hook-succeeded, so Helm deletes it the moment it passes. If the job is still there, it may have failed:

kubectl -n gen0sec logs job/g0s-db-migrate

Wait for the pods

kubectl -n gen0sec wait --for=condition=Ready pod --all --timeout=10m
Check readiness, not phase

A pod in CrashLoopBackOff still reports status.phase: Running. A --field-selector on phase reports a broken install as clean.

Two values that must match your cluster

Both are set in values-onprem.yaml and both point at the in-cluster object store. Check them if anything cannot read its data.

KeyMust be
global.commonEnv.S3_ENDPOINThttp://rustfs-svc.gen0sec-system.svc.cluster.local:9000
global.commonEnv.ARXIGNIS_DATA_URLThe same endpoint, plus the bucket: .../platform-data

ARXIGNIS_DATA_URL is where in-cluster services read their datasets from. It points at your object store, not at Gen0Sec. The bucket name at the end of it must match the bucket you created in step 5. If you used a different name there, change it here too.

The Service is rustfs-svc, with the -svc suffix. That suffix is the single most common cause of services that come up and then cannot read anything.

If it fails

SymptomStart here
ImagePullBackOffImagePullBackOff
Install aborts at g0s-db-migrateMigration failures
postgresql/core never reaches RunningPostgres will not start
Kafka broker crash loopsKafka will not start