diff --git a/.ai/reference.md b/.ai/reference.md
index edc8b9e80..65bec0b57 100644
--- a/.ai/reference.md
+++ b/.ai/reference.md
@@ -216,7 +216,7 @@ All Traefik guide pages follow this structure:
3. **Prerequisites** — `base/base-values.yaml` code block
4. **Configuration** — tabbed `customResources` (list) vs `customResourcesMap` (map) examples
5. **What Gets Created** or **How It Works** — table or bullet list
-6. **Verify** — `curl` commands (do not use `kubectl` — platform users do not have kubectl access; for certificate or TCP apps, mention the ArgoCD dashboard for resource status)
+6. **Verify** — `curl` commands are the primary check. `kubectl` is also acceptable when scoped to the reader's own environment namespace (e.g. `kubectl get pods -n CAPTAIN_NAMESPACE`), since developers now have namespace-scoped cluster access (see the "Access Your Cluster with kubectl" guide). Avoid cluster-wide commands (`-A`, `get namespaces`, node views) — RBAC forbids them. For certificate or TCP apps, the ArgoCD dashboard is also a good source for resource status.
7. **Key Points** — bullet list of important takeaways
8. **Admonitions** — `:::info`, `:::caution`, `:::warning` at the end
@@ -261,18 +261,20 @@ curl https://{{ include "app.name" . }}.apps.{{ .Values.captain_domain }}
## Captain Domain — Dynamic Domain Replacement
-The site replaces domain references dynamically so readers see their own cluster domain. There are **three patterns** depending on context:
+The site replaces domain references dynamically so readers see their own cluster domain. Choose the pattern that fits the context:
| Pattern | Where to use | Handled by |
|---------|--------------|-----------|
| `CAPTAIN_DOMAIN` | Inside code fences (` ``` `) | Swizzled `CodeBlock` component — replaces at render time |
+| `CAPTAIN_NAMESPACE` | Inside code fences, where a command needs the reader's environment namespace (first label of the captain domain, e.g. `nonprod`) | Swizzled `CodeBlock` component — replaces at render time |
| `` | Inline prose / paragraph text (non-URL domain names) | MDX component — renders current domain as styled text |
+| `` | Inline prose for the environment namespace (also accepts `tenant` / `tld`) | MDX component — renders that segment of the current domain |
| `` | `https://` URLs in prose that readers should visit | `CaptainDomainLink` component — clickable link when domain is customized, styled text with tooltip when default |
| `{{ .Values.captain_domain }}` | Helm template YAML inside code fences | Not replaced — displayed as-is (real Helm expression) |
### Rules
-1. **Code fences** — write `CAPTAIN_DOMAIN` as a literal sentinel. The swizzled CodeBlock replaces every occurrence with the reader's domain.
+1. **Code fences** — write `CAPTAIN_DOMAIN` as a literal sentinel. The swizzled CodeBlock replaces every occurrence with the reader's domain. Use `CAPTAIN_NAMESPACE` the same way for the environment namespace (the first label of the captain domain, e.g. `kubectl get pods -n CAPTAIN_NAMESPACE`).
2. **Prose text (non-URL domain names)** — use `` for bare domain names that are not clickable URLs. Files using this component **must** have a `.mdx` extension. Standard Docusaurus components like `` and `` work in `.md` files — only custom JSX components like `` require `.mdx`.
3. **Prose text (clickable URLs)** — use `` for any `https://` URL the reader should visit. The `to` prop uses `{domain}` as a placeholder. Optional `children` override the link text (e.g., `ArgoCD dashboard`). When the reader has set their domain, it renders as a clickable link opening in a new tab. When using the default domain, it renders as styled text with a tooltip prompting them to set their domain. Requires `.mdx` extension.
4. **Helm YAML in code fences** — use `{{ .Values.captain_domain }}`. This is the actual Helm expression and is intentionally left as-is.
diff --git a/docs/deploy-applications/access-cluster-kubectl.mdx b/docs/deploy-applications/access-cluster-kubectl.mdx
new file mode 100644
index 000000000..b4f5136d4
--- /dev/null
+++ b/docs/deploy-applications/access-cluster-kubectl.mdx
@@ -0,0 +1,118 @@
+---
+id: access-cluster-with-kubectl
+title: Access Your Cluster with kubectl
+type: tutorial
+---
+
+# Access Your Cluster with kubectl
+
+You can connect to your GlueOps environment with `kubectl` using your GitHub identity — no certificates or passwords to manage. Sign-in happens through your browser, and access is scoped to your team's environment namespace at one of three permission tiers.
+
+## Prerequisites
+
+- [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) installed on your machine.
+- [krew](https://krew.sigs.k8s.io/docs/user-guide/setup/install/) — the plugin manager for kubectl.
+- [oidc-login](https://github.com/int128/kubelogin#setup) — the kubectl credential plugin that handles the browser sign-in. Install it with krew (`kubectl krew install oidc-login`), and make sure `~/.krew/bin` is on your `PATH` so kubectl can find it.
+- Membership in one of your organization's kubectl GitHub teams (see [Access tiers](#access-tiers) below). Your Platform Administrator manages team membership.
+- Your kubeconfig, available from your cluster information page:
+
+:::tip
+If the domain above doesn't look right, update your **Captain Domain** in the top navigation bar.
+:::
+
+## 1. Create your kubeconfig
+
+Open your cluster information page at and copy the kubeconfig shown there. It comes with the cluster's certificate authority and sign-in configuration already embedded — no editing required.
+
+Paste it into kubectl's default config file:
+
+```bash
+mkdir -p ~/.kube
+# paste the copied kubeconfig into this file with your editor:
+nano ~/.kube/config
+```
+
+kubectl picks this file up automatically — no environment variables needed.
+
+:::caution
+If you already have a `~/.kube/config` for other clusters, don't overwrite it — merge the new cluster, context, and user entries into your existing file instead.
+:::
+
+## 2. Connect and sign in
+
+Run any command against your environment namespace — that's the first part of your Captain Domain, so for it is :
+
+```bash
+kubectl get pods -n CAPTAIN_NAMESPACE
+```
+
+The first time, kubectl starts a device sign-in flow:
+
+1. Your terminal prints a verification URL and a one-time code (your browser may open automatically).
+2. In the browser, enter the code if prompted and sign in with GitHub.
+3. Back in your terminal, the command completes and prints your pods.
+
+:::note
+Your token is cached after sign-in, so the browser step only happens occasionally — not on every command.
+:::
+
+To avoid typing `-n ` on every command, set it as your default once:
+
+```bash
+kubectl config set-context --current --namespace=CAPTAIN_NAMESPACE
+```
+
+## Access tiers
+
+Your permissions depend on which GitHub team you belong to. There are three tiers, each including everything from the tier above it:
+
+| Tier | GitHub team | What you can do |
+|---|---|---|
+| **Reader** | -kubectl-reader | View pods, logs, services, deployments, ingresses, and ExternalSecrets status. No access to secret values. |
+| **Debugger** | -kubectl-debugger | Everything in Reader, plus: exec into pods, port-forward, attach, restart deployments, and delete stuck pods. |
+| **Operator** | -kubectl-operator | Everything in Debugger, plus: delete any resource in your namespace. |
+
+To see which groups you signed in with, run `kubectl auth whoami`. To check whether your tier allows a specific action, run `kubectl auth can-i -n `.
+
+:::note
+The platform is GitOps-managed: you can't create or apply resources with kubectl. Deleting a resource causes ArgoCD to recreate it from your deployment-configurations repository — that's the intended way to force a clean resync.
+:::
+
+## Your access is namespace-scoped
+
+Every tier grants access **only within your environment namespace**. Commands that list across the cluster will be denied:
+
+:::caution
+`kubectl get namespaces`, `kubectl get pods -A`, and node-level views return `Forbidden`. This is by design, not an error — always work within your namespace with `-n ` or a default namespace set.
+:::
+
+## Troubleshooting
+
+### `Error from server (Forbidden)`
+
+In order of likelihood:
+
+1. Missing or wrong `-n ` — you only have access to your own environment namespace.
+2. The action needs a higher tier — check the [Access tiers](#access-tiers) table and verify with `kubectl auth can-i -n `.
+3. You're not in a kubectl GitHub team yet — contact your Platform Administrator.
+
+### Device code expired or browser window closed
+
+Just rerun your kubectl command — a fresh code is issued each time.
+
+### Signed in but permissions seem stale
+
+Tokens are cached locally. After your team membership changes, clear the cache and sign in again:
+
+```bash
+kubectl oidc-login clean
+```
+
+### Connection times out before any sign-in prompt
+
+API access is restricted to an IP allowlist. If you're connecting from a new network or VPN and the connection hangs or is refused without ever showing a sign-in prompt, ask your Platform Administrator to add your IP address.
+
+## Next steps
+
+- [Deploy Your First App](deploy-first-app) — deploy something to inspect with your new access.
+- [Add Secrets](manage-environment-secrets) — manage sensitive configuration the supported way.
diff --git a/docs/deploy-applications/hello-world-adding-configurations.mdx b/docs/deploy-applications/hello-world-adding-configurations.mdx
index 3b389d225..c918c8ef3 100644
--- a/docs/deploy-applications/hello-world-adding-configurations.mdx
+++ b/docs/deploy-applications/hello-world-adding-configurations.mdx
@@ -111,7 +111,7 @@ Visit chip (e.g. team names like
+ -kubectl-reader), blend it into the chip
+ instead of rendering a chip-within-a-chip. The custom-domain highlight
+ still applies via .captain-domain-custom. */
+code .captain-domain-inline {
+ background: transparent;
+ font-size: 1em;
+ padding: 0;
+}
+
+code .captain-domain-custom {
+ background: rgba(233, 171, 23, 0.12);
+ border: none;
+ border-radius: 0;
+ padding: 0 0.15em;
+}
+
/**
Captain Domain link (CaptainDomainLink component)
*/
diff --git a/src/theme/CodeBlock/index.tsx b/src/theme/CodeBlock/index.tsx
index ec01c29b9..5fc13df7d 100644
--- a/src/theme/CodeBlock/index.tsx
+++ b/src/theme/CodeBlock/index.tsx
@@ -3,10 +3,15 @@ import OriginalCodeBlock from '@theme-original/CodeBlock';
import { useCaptainDomain } from '@site/src/contexts/CaptainDomainContext';
const SENTINEL = 'CAPTAIN_DOMAIN';
+// First label of the captain domain is the environment namespace
+// (e.g. "nonprod" in nonprod.tenant.onglueops.com).
+const NAMESPACE_SENTINEL = 'CAPTAIN_NAMESPACE';
function replaceDomain(content: unknown, captainDomain: string): unknown {
if (typeof content === 'string') {
- return content.replaceAll(SENTINEL, captainDomain);
+ return content
+ .replaceAll(NAMESPACE_SENTINEL, captainDomain.split('.')[0] || '')
+ .replaceAll(SENTINEL, captainDomain);
}
return content;
}
diff --git a/tests-e2e/Dockerfile b/tests-e2e/Dockerfile
index bba82c61d..c25da694d 100644
--- a/tests-e2e/Dockerfile
+++ b/tests-e2e/Dockerfile
@@ -1,7 +1,7 @@
FROM mcr.microsoft.com/playwright:v1.58.2-noble@sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d
WORKDIR /tests
-RUN npm init -y && npm install @playwright/test@1.49.1
+RUN npm init -y && npm install @playwright/test@1.58.2
COPY captain-domain.spec.ts playwright.config.ts .
diff --git a/tests-e2e/captain-domain.spec.ts b/tests-e2e/captain-domain.spec.ts
index 6b8f466f1..9134dd64c 100644
--- a/tests-e2e/captain-domain.spec.ts
+++ b/tests-e2e/captain-domain.spec.ts
@@ -10,8 +10,11 @@ const PAGES = {
tlsRedirect: '/deploy-applications/traefik/traefik-tls-redirect',
clusterDomains: '/glueops-captain-domain',
introduction: '/introduction',
+ accessCluster: '/deploy-applications/access-cluster-with-kubectl',
};
+const DEFAULT_NAMESPACE = DEFAULT_DOMAIN.split('.')[0]; // first label, e.g. "nonprod"
+
async function gotoAndWait(page, path: string) {
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForSelector('#captain-domain-input', { timeout: 30000 });
@@ -227,6 +230,58 @@ test.describe('Captain Domain Feature', () => {
}
});
+ test('CAPTAIN_NAMESPACE sentinel renders the namespace, not the raw token', async ({ page }) => {
+ await gotoAndWait(page, PAGES.accessCluster);
+ await waitForCodeBlocks(page);
+
+ // No raw CAPTAIN_NAMESPACE (or CAPTAIN_DOMAIN) should remain in any code block
+ await page.waitForFunction(
+ () => {
+ const pres = document.querySelectorAll('pre');
+ for (const pre of pres) {
+ const text = pre.textContent || '';
+ if (text.includes('CAPTAIN_NAMESPACE') || text.includes('CAPTAIN_DOMAIN')) return false;
+ }
+ return pres.length > 0;
+ },
+ { timeout: 15000 }
+ );
+
+ const allText = await page.locator('pre').allTextContents();
+ for (const text of allText) {
+ expect(text, 'Raw CAPTAIN_NAMESPACE sentinel found').not.toContain('CAPTAIN_NAMESPACE');
+ }
+ // The default namespace (first label of the domain) should appear, e.g. "-n nonprod"
+ const hasNamespace = allText.some(t => t.includes(`-n ${DEFAULT_NAMESPACE}`));
+ expect(hasNamespace, `Expected "-n ${DEFAULT_NAMESPACE}" in a code block`).toBe(true);
+ });
+
+ test('CAPTAIN_NAMESPACE updates with a custom domain', async ({ page }) => {
+ await gotoAndWait(page, PAGES.accessCluster);
+ await waitForCodeBlocks(page);
+
+ const input = page.locator('#captain-domain-input');
+ await input.click();
+ await input.fill('staging.acme.onglueops.rocks');
+ await input.press('Enter');
+
+ // Namespace is the first label -> "staging"
+ await page.waitForFunction(
+ () => {
+ const pres = document.querySelectorAll('pre');
+ for (const pre of pres) {
+ if ((pre.textContent || '').includes('-n staging')) return true;
+ }
+ return false;
+ },
+ { timeout: 10000 }
+ );
+
+ const allText = await page.locator('pre').allTextContents();
+ expect(allText.some(t => t.includes('-n staging'))).toBe(true);
+ expect(allText.some(t => t.includes('-n nonprod'))).toBe(false);
+ });
+
test('inline in prose updates reactively', async ({ page }) => {
await gotoAndWait(page, PAGES.clusterDomains);