Use NetScaler credentials stored in a Vault server with NetScaler Kubernetes Gateway Controller

Last published : Nov 02, 2026
In most organizations, tier-1 NetScaler devices and Kubernetes clusters are managed by separate teams, and storing the NetScaler user name and password directly as Kubernetes secrets is not always acceptable. You can instead store the NetScaler credentials in a HashiCorp Vault server and have a Vault Agent sidecar deliver them to NetScaler Kubernetes Gateway Controller as files.
The gateway controller also hot-reloads those file-based credentials. When the credential rotates in Vault, the controller validates the new credential against the NetScaler and switches to it without a pod restart. If a rotation delivers an invalid credential, the controller does not use it and keeps the last-good credential. If you already changed the password on NetScaler, the last-good credential no longer works either, so the controller cannot log in to NetScaler until you fix the value in Vault (see Step 7). The controller checks for rotated credentials every 30 seconds by default. To change the interval, set the NS_CRED_RELOAD_INTERVAL environment variable.
This feature needs NetScaler Kubernetes Gateway Controller 2.3.0 or later.
Vault pages for the other NetScaler controllers:
  • Use NetScaler credentials stored in a Vault server with NetScaler Node Controller
  • Use NetScaler credentials stored in a Vault server with NetScaler Ingress Controller
  • Use NetScaler credentials stored in a Vault server with NetScaler GSLB controller
For the metrics exporter, see Metrics exporter.

How it works

A Vault Agent sidecar in the gateway controller pod authenticates to Vault (Kubernetes auth) and renders two files, username and password, into an in-memory volume that the Helm chart mounts at vaultCredentials.mountPath (default /etc/citrix). The controller reads these files first, and falls back to the NS_USER and NS_PASSWORD environment variables if they are absent or empty. A built-in watcher re-reads the files on change, validates the new credential against the NetScaler, and adopts it without a restart.
  1. Store: an administrator or a pipeline puts the NetScaler user name and password into Vault under a KV path, for example secret/netscaler/gateway.
  2. Deliver: the Vault Agent sidecar logs in to Vault with the pod's service account token and writes username and password into the shared in-memory volume.
  3. Consume: the controller reads those files (see Credential precedence). Its watcher notices any change, validates the new credential, and switches over live.
  4. Rotate: update the NetScaler password and the Vault secret. The sidecar rewrites the files and the controller hot-reloads the credential (see Step 7).

Credential precedence (vaultCredentials vs netscaler.adcCredentialSecret)

The Vault files and the Kubernetes secret netscaler.adcCredentialSecret (passed to the controller as the NS_USER and NS_PASSWORD environment variables) can be used together. The controller resolves each field (user name and password) separately, in this order:
  1. <mountPath>/auth/<NS_IP>/<field>
  2. <mountPath>/<field> (written by the Vault Agent)
  3. NS_USER or NS_PASSWORD (from netscaler.adcCredentialSecret)
A file that has a value always takes precedence over the secret. If a file does not exist, is empty, or contains only white space, the controller skips it and uses the next source in the list.
vaultCredentials.suppressEnvCredentials controls whether the chart passes netscaler.adcCredentialSecret to the controller:
vaultCredentials.enabled suppressEnvCredentials Credentials in the pod Effective source
false (n/a) netscaler.adcCredentialSecret only The secret. No hot-reload.
true true (default) Vault files only The Vault files. netscaler.adcCredentialSecret is not needed.
true false The Vault files and netscaler.adcCredentialSecret The Vault files. The secret is a start-up fallback.
Important: Only the files are hot-reloaded. The controller reads the environment variables once, at start-up. For a Vault-only deployment, keep the default suppressEnvCredentials: true.

Prerequisites

  • The prerequisites for deploying NetScaler Kubernetes Gateway Controller. For more information, see Deploy NetScaler Kubernetes Gateway Controller.
  • helm, plus the NetScaler Helm charts: helm repo add netscaler https://netscaler.github.io/netscaler-helm-charts/.
  • The NetScaler management IP address and a NetScaler system user for the controller. You store the user name and password of that user in Vault.
  • A HashiCorp Vault server. If you do not have one, install it in the cluster in Step 0.

One-time Vault setup

Steps 0, 1, and 2 install Vault and set up its Kubernetes authentication. You do them once for each Vault server and cluster. If you already did these steps for another NetScaler controller in this cluster, go to Step 3.

Step 0: Install HashiCorp Vault (skip if you already have one)

If you already run Vault, make sure that the KV v2 secrets engine is enabled and note its address (for example https://<VAULT URL>:8200), then go to Step 1. Otherwise, you can run Vault in the cluster with its official Helm chart.
  1. Add the HashiCorp Helm repository and install Vault:
    helm repo add hashicorp https://helm.releases.hashicorp.com
    helm repo update
    helm install vault hashicorp/vault -n vault --create-namespace
    Note: For a quick proof of concept, you can add --set "server.dev.enabled=true". Dev mode keeps all data in memory, starts unsealed, and uses the root token root. It is not secure, so use it only with test credentials, never in production. For production, leave it out and complete item 2.
  2. Production only: initialize and unseal Vault once, and save the unseal keys and the root token somewhere safe:
    kubectl -n vault exec -it vault-0 -- vault operator init
    # run the following once per unseal key that `init` printed (default: 3 of 5);
    # it asks for the key without showing it
    kubectl -n vault exec -it vault-0 -- vault operator unseal
  3. Open a shell in the Vault pod, log in, and enable the KV v2 engine at path secret/ (dev mode already has it):
    kubectl -n vault exec -it vault-0 -- sh
    export VAULT_ADDR=http://127.0.0.1:8200
    vault login   # asks for the root token without showing it
    vault secrets enable -path=secret -version=2 kv   # skip if "secret/" already exists
    For this in-cluster install (namespace vault), the address that the Vault Agent sidecar uses is http://vault.vault:8200. Otherwise, use the URL of your Vault server. You need it in Step 5.
    This install serves Vault over plain HTTP. For production, turn on TLS in the Vault Helm chart, use an https:// address, and give the Vault Agent the Vault CA certificate (mount the CA file and set the VAULT_CACERT environment variable).
    Important: The root token can do anything in Vault. Use it only for this initial setup. For later changes, such as the rotation in Step 7, use an administrator login with limited rights, and revoke the root token when you no longer need it. For more information, see Tokens in the Vault documentation.

Step 1: Create a service account for Kubernetes authentication

Vault uses this service account to check the tokens that the controller pods present (the Kubernetes TokenReview API). If you already created one for another NetScaler controller in this cluster, you can reuse it.
  1. Create the service account vault-auth and bind it to the system:auth-delegator ClusterRole. Save the following as vault-auth-service-account.yaml, replace <namespace> with your namespace, and apply it:
    apiVersion: v1
    kind: ServiceAccount
    metadata:
      name: vault-auth
      namespace: <namespace>
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRoleBinding
    metadata:
      name: vault-auth-tokenreview
    roleRef:
      apiGroup: rbac.authorization.k8s.io
      kind: ClusterRole
      name: system:auth-delegator
    subjects:
      - kind: ServiceAccount
        name: vault-auth
        namespace: <namespace>
    kubectl apply -f vault-auth-service-account.yaml
  2. On Kubernetes 1.24 and later, a service account no longer gets a token secret automatically. Create one. Save the following as vault-auth-token.yaml:
    apiVersion: v1
    kind: Secret
    metadata:
      name: vault-auth-token
      annotations:
        kubernetes.io/service-account.name: vault-auth
    type: kubernetes.io/service-account-token
    Apply it in the same namespace and export the token and the CA certificate:
    kubectl apply -n &lt;namespace> -f vault-auth-token.yaml
    export SA_JWT_TOKEN=$(kubectl get secret -n &lt;namespace> vault-auth-token -o jsonpath="{.data.token}" | base64 --decode)
    export SA_CA_CRT=$(kubectl get secret -n &lt;namespace> vault-auth-token -o jsonpath="{.data['ca\.crt']}" | base64 --decode)
    Note:
    When Vault runs in the same cluster, you can leave out token_reviewer_jwt in Step 2. Vault then uses its own pod token to check tokens, and you do not store a token that stops working when its service account is deleted.

Step 2: Set up Kubernetes authentication on Vault

Log in to the Vault server and run the following:
  1. Enable Kubernetes authentication. If it is already enabled, Vault reports that the path is already in use; skip this command.
    vault auth enable kubernetes
  2. Configure how Vault reaches the cluster:
    vault write auth/kubernetes/config

    token_reviewer_jwt="$SA_JWT_TOKEN"

    kubernetes_host="https://&lt;K8S_CLUSTER_URL>:&lt;API_SERVER_PORT>"

    kubernetes_ca_cert="$SA_CA_CRT"

Step 3: Store the credentials and allow the gateway controller to read them

Log in to the Vault server and run the following:
  1. Store the NetScaler credentials of the gateway controller:
    vault kv put secret/netscaler/gateway username='&lt;NetScaler user name>' password='&lt;password>'
    Note:
    • A KV secret does not rotate by itself. You rotate it in Step 7.
    • A password that you type in a command stays in your shell history. To avoid that, first read the password into a variable without showing it (for example, read -rs PASSWORD in bash), and then use password="$PASSWORD" in the command.
  2. Create a read-only policy for this secret. KV v2 stores data under secret/data/... and metadata under secret/metadata/..., so grant read on both.
    netscaler-gateway-kv-ro.hcl:
    path "secret/data/netscaler/gateway" {
      capabilities = ["read"]
    }
    path "secret/metadata/netscaler/gateway" {
      capabilities = ["read"]
    }
    Then load it:
    vault policy write netscaler-gateway-kv-ro netscaler-gateway-kv-ro.hcl
  3. Create a role that lets the gateway controller's service account use the policy. The Vault Agent sidecar logs in with the token of the service account that the controller pod runs as. This example uses the service account nsgw-vault, which you set with gatewayController.serviceAccount.name in Step 5. Replace <gateway-namespace> with the namespace of the gateway controller.
    vault write auth/kubernetes/role/netscaler-gateway

    bound_service_account_names=nsgw-vault

    bound_service_account_namespaces=&lt;gateway-namespace>

    policies=netscaler-gateway-kv-ro

    ttl=24h
    Use a role name that no other controller uses. Writing a role with the same name replaces it, so another controller that uses that role can no longer log in to Vault.

Step 4: Create the Vault Agent configuration

Create a Vault Agent configuration that writes the two files. Keep exit_after_auth = false, so that the agent keeps running and rewrites the files when the credential rotates. static_secret_render_interval makes the agent check Vault for changes every 30 seconds; the default is 5 minutes.
vault-agent-config.hcl:
exit_after_auth = false
pid_file        = "/home/vault/pidfile"

auto_auth {
  method "kubernetes" {
    mount_path = "auth/kubernetes"
    config = { role = "netscaler-gateway" }
  }
  sink "file" { config = { path = "/home/vault/.vault-token" } }
}

template_config {
  static_secret_render_interval = "30s"
}

template {
  destination = "/etc/citrix/username"
  perms       = "0444"
  contents    = "{{ with secret \"secret/data/netscaler/gateway\" }}{{ .Data.data.username }}{{ end }}"
}
template {
  destination = "/etc/citrix/password"
  perms       = "0444"
  contents    = "{{ with secret \"secret/data/netscaler/gateway\" }}{{ .Data.data.password }}{{ end }}"
}
The templates write the files with mode 0444, because the Vault Agent runs as a different user than the gateway controller. Only the containers that mount the citrix-creds volume can read the files.
Create a ConfigMap from it in the namespace of the gateway controller:
kubectl create configmap gateway-vault-agent-config --from-file=./vault-agent-config.hcl -n <gateway-namespace>
Note: Pin the image version, for example hashicorp/vault:1.21. The plain vault Docker image is deprecated.

Step 5: Deploy the gateway controller with Vault credentials

Set vaultCredentials.enabled to true and add the Vault Agent as a sidecar with extraContainers. The chart creates the shared citrix-creds in-memory volume. In this chart, extra pod volumes go under gatewayController.extraVolumes, not a top-level extraVolumes. Add the agent's volumes there, including a projected service account token that the agent uses to log in to Vault.
vault-values.yaml:
vaultCredentials:
  enabled: true                # netscaler.adcCredentialSecret is not needed

gatewayController:
  serviceAccount:
    name: nsgw-vault           # the service account that the Vault role in Step 3 allows
  extraVolumes:
    - name: vault-config
      configMap: { name: gateway-vault-agent-config }
    - name: vault-token
      emptyDir: { medium: Memory }
    - name: sa-token
      projected:
        sources:
          - serviceAccountToken: { path: token, expirationSeconds: 3600, audience: vault }
          - configMap: { name: kube-root-ca.crt, items: [{ key: ca.crt, path: ca.crt }] }
          - downwardAPI: { items: [{ path: namespace, fieldRef: { fieldPath: metadata.namespace } }] }

extraContainers:
  - name: vault-agent
    image: hashicorp/vault:1.21
    args: ["agent", "-config=/etc/vault/vault-agent-config.hcl", "-log-level=info"]
    env:
      - name: VAULT_ADDR
        value: "https://<VAULT URL>:8200"
      - name: SKIP_SETCAP      # needed when the container does not run as root, for example on OpenShift
        value: "true"
    volumeMounts:
      - { name: vault-config, mountPath: /etc/vault }
      - { name: vault-token,  mountPath: /home/vault }
      - { name: citrix-creds, mountPath: /etc/citrix }
      - { name: sa-token,     mountPath: /var/run/secrets/kubernetes.io/serviceaccount, readOnly: true }
The projected token has the audience vault, so it works for logging in to Vault but not against the Kubernetes API.
Install the gateway controller with its mandatory values:
helm install gw netscaler/netscaler-kubernetes-gateway-controller -f vault-values.yaml -n <gateway-namespace> --set netscaler.nsIP=<NSIP>,license.accept=yes,gatewayController.entityPrefix=<prefix>,gatewayController.gatewayControllerName=<name>
The Vault-related values of the netscaler-kubernetes-gateway-controller chart:
Value Default Description
vaultCredentials.enabled false Read the NetScaler credentials from files and hot-reload them.
vaultCredentials.mountPath /etc/citrix The path where the Vault Agent writes the files.
vaultCredentials.suppressEnvCredentials true Do not set NS_USER and NS_PASSWORD from netscaler.adcCredentialSecret. Set it to false to keep the secret as a start-up fallback.
vaultCredentials.credentialWaitSeconds 120 How long the controller waits at start-up for the files before it fails.
extraContainers [] Extra containers in the pod, such as the Vault Agent sidecar.
gatewayController.extraVolumes / gatewayController.extraVolumeMounts [] Extra pod volumes, and extra volume mounts for the gateway controller container.
extraEnv [] Extra environment variables for the gateway controller container.
The following example sets every vaultCredentials value explicitly and adds NS_CRED_RELOAD_INTERVAL through extraEnv, so that the controller checks for rotated credentials every 60 seconds:
vaultCredentials:
  enabled: true
  mountPath: /etc/citrix         # where the sidecar writes the username and password files
  credentialWaitSeconds: 300     # wait up to 5 minutes at start-up for the files
  suppressEnvCredentials: true   # set to false to keep netscaler.adcCredentialSecret as a start-up fallback
extraEnv:
  - name: NS_CRED_RELOAD_INTERVAL
    value: "60"

Step 6: Verify

Check the controller logs. Find the deployment name with kubectl get deploy -n <gateway-namespace>, then run:
kubectl logs deploy/<deployment-name> -c nskgwc -n <gateway-namespace> | grep -iE "hot-reload|CredentialWatcher|credentials"
Expected:
  • Credential hot-reload ENABLED
  • Starting CredentialWatcher ... for <NSIP>
  • The pod becomes Ready.

Step 7: Rotate the credentials

  1. On the NetScaler, change the password of the user: set system user <user> <newpassword>.
  2. Right after that, update the Vault secret to the same value: vault kv put secret/netscaler/gateway username='<user>' password='<newpassword>'.
  3. Within 30 seconds, the Vault Agent picks up the change and rewrites the files. The controller validates the new credential and switches to it at its next check (NS_CRED_RELOAD_INTERVAL, default 30 seconds), so the switch takes about a minute. There is no restart, and traffic is not affected.
  4. If the new value is wrong, the controller does not use it. It keeps the last-good credential and logs Credential hot-reload: probe FAILED. Because you changed the password on NetScaler in item 1, the last-good credential no longer works either, so the controller cannot log in to NetScaler. The controller keeps testing the new value, but after repeated failures it waits longer between tests, up to 10 minutes. Fix the value in Vault right away.
  5. After you fix the value, the controller switches to it at its next test, which can take 10 minutes or more.

Metrics exporter

The gateway controller Helm chart does not deploy the metrics exporter with the gateway controller. If you run the metrics exporter separately for the same NetScaler, give it the same NetScaler credentials as the gateway controller, for example from the same Vault secret written to /mnt/nslogin/username and /mnt/nslogin/password in the exporter container. For more information about the exporter, see the NetScaler metrics exporter documentation.
To do this, add a Vault Agent init container to the exporter pod. Like the sidecar in Step 5, it runs the HashiCorp Vault image (hashicorp/vault) with the agent command, so it is a Vault client, not a Vault server. It uses the gateway-vault-agent-config ConfigMap from Step 4 and logs in to Vault with the exporter's service account token. It writes username and password to a shared in-memory volume before the exporter starts, and then exits, because the -exit-after-auth flag overrides exit_after_auth = false in that configuration. The exporter reads the files from that volume at /mnt/nslogin. The exporter pod needs only this init container, not a Vault Agent sidecar.
  1. Create a service account for the exporter in the namespace of the gateway controller. Run the exporter in that namespace, so that it can use the ConfigMap from Step 4.
    kubectl create serviceaccount &lt;exporter-service-account> -n &lt;gateway-namespace>
  2. Allow this service account in the Vault role. Run the vault write auth/kubernetes/role/netscaler-gateway command from Step 3 again with both service accounts: bound_service_account_names=<controller-service-account>,<exporter-service-account>. The controller's service account is nsgw-vault in the Step 5 example. The command replaces the role, so if you leave out the controller's service account, the gateway controller can no longer log in to Vault.
  3. Add the following to the pod spec (spec.template.spec) of your exporter deployment:
    automountServiceAccountToken: false              # the exporter does not need a token; the init container mounts its own
    serviceAccountName: <exporter-service-account>   # the service account that you allowed in the Vault role
    initContainers:
      - name: vault-agent-init
        image: hashicorp/vault:1.21   # run as a Vault Agent (a Vault client), not a Vault server
        args: ["agent", "-config=/etc/vault/vault-agent-config.hcl", "-exit-after-auth"]   # write the files once, then exit
        env:
          - name: VAULT_ADDR
            value: "https://<VAULT URL>:8200"
          - name: SKIP_SETCAP      # needed when the container does not run as root, for example on OpenShift
            value: "true"
        volumeMounts:
          - { name: vault-config, mountPath: /etc/vault }
          - { name: vault-token,  mountPath: /home/vault }
          - { name: citrix-creds, mountPath: /etc/citrix }
          - { name: sa-token,     mountPath: /var/run/secrets/kubernetes.io/serviceaccount, readOnly: true }
    containers:
      - name: exporter
        image: quay.io/netscaler/netscaler-adc-metrics-exporter:1.7.0
        # keep the args and other settings of your exporter, but remove any other mount at /mnt/nslogin, such as the nslogin secret
        volumeMounts:
          - { name: citrix-creds, mountPath: /mnt/nslogin, readOnly: true }
    volumes:
      - name: citrix-creds
        emptyDir: { medium: Memory }
      - name: vault-config
        configMap: { name: gateway-vault-agent-config }
      - name: vault-token
        emptyDir: { medium: Memory }
      - name: sa-token
        projected:
          sources:
            - serviceAccountToken: { path: token, expirationSeconds: 3600, audience: vault }
            - configMap: { name: kube-root-ca.crt, items: [{ key: ca.crt, path: ca.crt }] }
            - downwardAPI: { items: [{ path: namespace, fieldRef: { fieldPath: metadata.namespace } }] }
Note:
  • If the init container cannot log in to Vault or read the secret, it keeps retrying, and the exporter pod stays in the Init state (see kubectl get pods -n <gateway-namespace>). To see the error, run kubectl logs <exporter-pod-name> -c vault-agent-init -n <gateway-namespace>.
  • The exporter logs in to NetScaler at its first scrape, not when it starts. To check that it logged in, run kubectl logs <exporter-pod-name> -c exporter -n <gateway-namespace> and look for ADC Session Login Successful.
  • After each credential rotation (Step 7), wait until the gateway controller logs Credential hot-reload: promoted, and then restart the exporter pod, for example: kubectl rollout restart deployment/<exporter-deployment-name> -n <gateway-namespace>.
The init container is required because the exporter reads /mnt/nslogin/username and /mnt/nslogin/password only once, when it starts. It does not wait for the files and never reads them again. If the files do not exist yet when the exporter starts, it cannot log in to NetScaler. It might log no error, but its citrixadc_probe_success metric is 0. Kubernetes starts the other containers of a pod only after the init containers finish, so with the init container the files exist before the exporter starts. A Vault Agent sidecar does not work reliably for the exporter: Kubernetes does not wait for a sidecar to write the files before it starts the exporter, and the exporter does not read the new files that a sidecar writes after a rotation.
The restart after a rotation is needed for the same reason. The gateway controller switches to the new password, but the exporter keeps the old one. It keeps working while its current NetScaler session lasts. When the session ends, the exporter can no longer log in, because it still uses the old password. In the restarted pod, the init container writes the new password before the exporter starts.