Temporal Stack
June 12, 2026 · View on GitHub
Why this exists
The official Temporal Helm chart is the foundation of this project — it handles all the complexity of deploying the Temporal server components (frontend, history, matching, worker) and does so extremely well. Without it, building something like this would require an enormous amount of work.
What the upstream chart deliberately leaves to the operator is the surrounding infrastructure — a database, an observability stack, archival storage, and a way to manage dynamic config in Kubernetes. This is by design: those choices are environment-specific and the upstream chart is right not to make them for you.
This chart — a Helm "super-chart" or umbrella chart — builds on top of the upstream chart and makes those choices for a local Docker Desktop Kubernetes environment. It wraps the upstream chart as a subchart and adds the surrounding infrastructure as properly integrated dependencies:
- A PostgreSQL instance for persistence
- Prometheus and Grafana for metrics and dashboards
- A log aggregation stack (Loki + Promtail)
- MinIO for workflow archival
- A ConfigMap-based dynamic config client with live reload
The result is a complete, realistic starting point that reflects how a Temporal cluster actually runs — not just the server in isolation.
What's included
- Temporal Server (frontend ×2, history ×2, matching ×2, worker ×1, internal-frontend ×1)
- PostgreSQL (three isolated instances: main store, primary visibility, secondary visibility)
- Prometheus + Grafana (pre-loaded dashboards and alerts)
- Loki + Promtail (log aggregation)
- MinIO (workflow and visibility archival)
- Health poller (drives per-host
host_healthmetrics) - ConfigMap-based dynamic config with live reload (no pod restarts required)
- Dual visibility support (hot standby visibility store with parallel writes, opt-in via
dualVisibility.enabled) - JWT authentication via bundled Dex OIDC provider — UI login, SDK worker auth, CLI auth all enforced out of the box
Everything is pre-wired. No manual configuration required to get started.
Running Docker Compose instead of Kubernetes? See my-temporal-dockercompose for the companion Docker Compose setup.
Table of contents
- Prerequisites
- Installing the chart
- Accessing the services
- Uninstalling / Starting Fresh
- Switching between this chart and Docker Compose
- Updating dashboards
- Dynamic config
- MinIO and Archival
- Scaling Temporal Services
- Dual Visibility
- Authentication
- Useful Kubernetes commands
- Troubleshooting
- Upgrading Temporal Server
Prerequisites
You need the following installed before you can run this chart.
1. Docker Desktop
Download and install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop/
Allocate enough resources — recommended minimums:
- CPU: 6 cores
- Memory: 12 GB
- Disk: 60 GB
To set these: Docker Desktop → Settings (gear icon) → Resources.
2. Kubernetes (via Docker Desktop)
This chart runs on a local Kubernetes cluster provided by Docker Desktop.
- Open Docker Desktop
- Click the Kubernetes icon in the left sidebar
- Click Create cluster
- Select kubeadm, keep defaults (1 node, latest version)
- Click Create
- Wait for the cluster to show Active
Important: Select kubeadm, not kind. kubeadm supports NodePort services accessible on localhost out of the box, which is how this chart exposes its UIs without port-forwarding.
3. kubectl
Docker Desktop installs kubectl automatically and sets the docker-desktop context — no separate installation needed.
Verify it is pointed at your local cluster:
kubectl config current-context
# should output: docker-desktop
kubectl get nodes
# should show one node with STATUS: Ready
4. Helm
Helm is the Kubernetes package manager used to install this chart. Docker Desktop does not bundle it — install separately:
brew install helm
Verify (v3+ required):
helm version
5. host.docker.internal in /etc/hosts (auth only)
When auth.enabled: true, the bundled Dex OIDC provider uses host.docker.internal as its issuer URL — a hostname that must resolve identically from your browser, from inside Kubernetes pods, and from the Temporal server. Docker Desktop is supposed to add this automatically but sometimes omits it.
Check if it's present:
grep host.docker.internal /etc/hosts
If missing, add it (one-time):
echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts
If you run with auth.enabled: false this step is not required.
Installing the chart
Custom images
install.sh checks for the custom images automatically and builds them if they are missing — no manual build step required on a fresh setup.
The chart uses two locally-built images:
temporal-custom-server— Temporal server with several custom extensions compiled in:- ConfigMap dynconfig (temporal-configmap-dynconfig) — watches the
temporal-dynconfigConfigMap and applies changes live without pod restarts - Custom authorizer — wraps the default authorizer and adds task queue blocking via
TEMPORAL_BLOCKED_TASK_QUEUESenv var;authorizer=defaultis always active so no JWT = denied - Custom claim mapper — falls back to email-based role assignment when the JWT has no
permissionsclaim (Dex static passwords); production IDPs that issuepermissionsclaims use the default mapper path automatically - Plaintext payload interceptor — detects unencrypted payload encodings (
json/plain,binary/plain) on all major frontend APIs, logs a warning, and increments aplaintext_payload_detected_totalmetric; observe-only, requests always pass through
- ConfigMap dynconfig (temporal-configmap-dynconfig) — watches the
temporal-health-poller— CallsAdminHandler.DeepHealthCheckon each history pod and emits thehost_healthgauge to Prometheus.
The server version is controlled by appVersion in Chart.yaml — that is the single source of truth. install.sh reads it automatically when building images. To target a different version, update appVersion in Chart.yaml first, then run install.sh.
Images are built from ~/devel/temporal/temporal at the tag matching appVersion and cached in Docker Desktop — no registry push needed. To rebuild manually (e.g. after a version change):
bash build.sh --server-version v1.31.0
See UPGRADE.md when changing server versions.
Run the install script
cd ~/devel/temporal-helm-superchart
bash install.sh
The script handles everything in the correct order:
- Checks for custom Docker images — builds them automatically if missing
- Adds and updates all required Helm repositories
- Installs Prometheus Operator CRDs (required before kube-prometheus-stack)
- Installs PostgreSQL (all 3 instances when dual visibility is enabled) and waits for them to be fully reachable from within the cluster
- Installs the full stack — Temporal, Prometheus, Grafana, Loki, MinIO, and Dex (when auth is enabled)
- Waits for Temporal frontend, worker, Grafana, and Dex to be ready
- Creates the
defaultTemporal namespace (routed viainternal-frontendto bypass JWT enforcement)
The first install takes 5–10 minutes as images are pulled. You will see output like:
Temporal UI: http://localhost:30080
(login: admin@temporal.io / admin)
Grafana: http://localhost:30300 (admin/admin)
Prometheus: http://localhost:30090
MinIO: http://localhost:30901 (minioadmin/minioadmin)
Temporal gRPC: localhost:7233
(JWT required for SDK workers + CLI — see README Authentication section)
Verify
kubectl get pods -n temporal
All pods should show Running or Completed.
Accessing the services
Once installed, the following services are available directly — no port-forwarding required:
| Service | URL | Notes |
|---|---|---|
| Temporal UI | http://localhost:30080 | Workflow management — redirects to Dex login. Default user: admin@temporal.io / admin. See Authentication. |
| Grafana | http://localhost:30300 | Dashboards (admin/admin) |
| Prometheus | http://localhost:30090 | Metrics |
| MinIO Console | http://localhost:30901 | Archival storage — login: minioadmin / minioadmin. History and visibility archival is enabled on the default namespace automatically at install time. |
| Temporal Frontend (gRPC) | localhost:7233 | SDK target — default port, no config needed. Kubernetes load-balances across both frontend replicas automatically. Requires a JWT bearer token — see Authentication. |
| Dex (OIDC) | http://host.docker.internal:30556/dex | Local identity provider — issues JWTs for UI, CLI, and SDK workers. Must use host.docker.internal (not localhost) — this hostname resolves identically from browser, pods, and the Temporal server. |
Uninstalling / Starting Fresh
Use teardown.sh to completely remove the running cluster. It handles the helm.sh/resource-policy: keep ConfigMap, the namespace, and optionally the Docker images — all in one step.
Teardown + reinstall (reuse existing images):
bash teardown.sh --keep-images
bash install.sh
Full reset including a clean image rebuild:
bash teardown.sh
bash build.sh --server-version v1.31.0
bash install.sh
By default
teardown.shremoves the custom Docker images. Always use--keep-imagesif you plan to reinstall without rebuilding, otherwiseinstall.shwill fail withErrImageNeverPull.
The
temporal-dynconfigConfigMap hashelm.sh/resource-policy: keep, sohelm uninstallalone does not remove it.teardown.shdeletes it explicitly before removing the namespace.
Switching between this chart and Docker Compose
See my-temporal-dockercompose for the companion Docker Compose setup.
You cannot run both at the same time. Both setups bind port 7233 on localhost for the Temporal frontend — whichever starts second will fail to bind.
Switch from K8s to Docker Compose
# 1. Tear down the K8s stack (keep images so you can switch back without rebuilding)
cd ~/devel/temporal-helm-superchart
bash teardown.sh --keep-images
# 2. Start Docker Compose
cd ~/devel/my-temporal-dockercompose
docker compose -f compose-postgres.yml -f compose-services.yml up -d
Switch from Docker Compose back to K8s
# 1. Stop Docker Compose
cd ~/devel/my-temporal-dockercompose
docker compose -f compose-postgres.yml -f compose-services.yml down
# 2. Reinstall the K8s stack
cd ~/devel/temporal-helm-superchart
bash install.sh
The Kubernetes cluster itself keeps running in the background while Docker Compose is active — you only need to tear down the Helm release, not the cluster.
Updating dashboards
Grafana dashboards are stored as Kubernetes ConfigMaps and provisioned at pod startup. To update a dashboard:
- Replace the JSON file in
files/dashboards/ - Run
helm upgradeto push the updated ConfigMap:
helm upgrade temporal-stack . --namespace temporal --reuse-values
- Restart the Grafana pod to load the new dashboard:
kubectl rollout restart deployment/temporal-stack-grafana -n temporal
kubectl rollout status deployment/temporal-stack-grafana -n temporal --timeout=2m
Grafana will be back at http://localhost:30300 within ~30 seconds.
Dynamic config
What it is
Temporal has hundreds of runtime parameters — rate limits, cache sizes, task queue settings, retention policies — that can be changed without restarting the server. These are called dynamic config.
In a standard Temporal deployment you manage these via a config file on disk, which means SSHing into servers or redeploying to make a change. This chart replaces that with a Kubernetes ConfigMap — a key-value store that lives inside the cluster. The custom server image has temporal-configmap-dynconfig compiled in, which watches the ConfigMap for changes using the Kubernetes Watch API. The moment you update the ConfigMap, all server pods see the change within seconds — no restart, no redeploy.
Viewing the current config
kubectl get configmap temporal-dynconfig -n temporal -o jsonpath='{.data.config\.yaml}'
On a fresh install the ConfigMap is mostly empty — any key not explicitly set falls back to Temporal's compiled-in default.
Making a change
Create a YAML file with the keys you want to set, then apply it:
# 1. Create a file with your changes (or edit an existing one)
cat > my-dynconfig.yaml << 'EOF'
frontend.namespaceRPS.visibility:
- value: 100
constraints: {}
EOF
# 2. Apply it to the ConfigMap
kubectl create configmap temporal-dynconfig \
--from-file=config.yaml=my-dynconfig.yaml \
--namespace temporal \
--dry-run=client -o yaml | kubectl apply -f -
All server pods pick up the change within seconds. No restart required.
Value format
Each key is a list of values with optional constraints. A value with constraints: {} is the global default. You can add per-namespace overrides:
frontend.globalNamespaceRPS:
- value: 500
constraints:
namespace: my-high-traffic-namespace # applies only to this namespace
- value: 1200
constraints: {} # global fallback for all other namespaces
Temporal evaluates constraints top-to-bottom — most specific wins.
Reverting a value
Remove the key from your YAML file and re-apply. The server falls back to its compiled-in default within seconds.
Using with multiple clusters
Each ConfigMap is scoped to a Kubernetes namespace. If you run two separate Temporal clusters in two namespaces (e.g. temporal-a and temporal-b), each has its own temporal-dynconfig ConfigMap and its own independent set of dynamic config values — changes to one cluster do not affect the other.
There is no native Kubernetes mechanism to share a ConfigMap across namespaces — a pod in temporal-a cannot watch a ConfigMap in temporal-b. The recommended approach is to keep a single source-of-truth YAML file under version control and apply it to each namespace when you want to sync:
kubectl create configmap temporal-dynconfig \
--from-file=config.yaml=my-dynconfig.yaml \
--namespace temporal-a \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create configmap temporal-dynconfig \
--from-file=config.yaml=my-dynconfig.yaml \
--namespace temporal-b \
--dry-run=client -o yaml | kubectl apply -f -
This keeps config changes auditable and consistent without adding extra tooling.
Reference
To see all available keys and their compiled-in defaults, check the dynamic config reference.
MinIO and Archival
MinIO is deployed as a pod inside the temporal namespace and is used as the archival backend for workflow history and visibility. The default Temporal namespace has archival enabled automatically at install time — no manual setup required.
The MinIO console is available at http://localhost:30901 (login: minioadmin / minioadmin). Archived workflow files appear in the temporal-history and temporal-visibility buckets.
Using with multiple clusters
MinIO is namespace-scoped — it is only directly accessible to services in the temporal namespace. If you run a second Temporal cluster in a different namespace (e.g. temporal-b), its pods cannot reach this MinIO instance by default.
You have two options:
Option 1 — Separate MinIO per cluster (default, simplest) Each cluster gets its own MinIO deployed in its own namespace. Fully isolated, no cross-namespace dependencies. This is what this chart does out of the box.
Option 2 — Shared MinIO across clusters
Deploy MinIO once in a dedicated namespace (e.g. minio) and point each Temporal cluster at it using the full Kubernetes DNS name:
http://temporal-stack-minio.minio.svc.cluster.local:9000
Each cluster uses different bucket names (e.g. cluster-a-history, cluster-b-history) to keep archived data separate. Update values.yaml on each cluster to point at the shared endpoint:
temporal:
server:
archival:
history:
provider:
customStores:
minio:
endpoint: "http://temporal-stack-minio.minio.svc.cluster.local:9000"
For a local dev setup, Option 1 is the right default. Option 2 is useful when running multiple clusters and you want a single place to browse all archived workflows.
Scaling Temporal Services
The chart defaults are sized for local development (frontend ×2, history ×2, matching ×2, worker ×1). You can scale any service by updating values.yaml and running helm upgrade.
Scale via values.yaml (persistent)
Edit values.yaml and change the replica counts:
temporal:
server:
frontend:
replicaCount: 3
history:
replicaCount: 5
matching:
replicaCount: 3
worker:
replicaCount: 2
Then apply:
helm upgrade temporal-stack . --namespace temporal --reuse-values
Scale via kubectl (temporary)
For a quick one-off change without touching values.yaml — note this will be overwritten on the next helm upgrade:
kubectl scale deployment temporal-stack-frontend -n temporal --replicas=3
kubectl scale deployment temporal-stack-history -n temporal --replicas=5
kubectl scale deployment temporal-stack-matching -n temporal --replicas=3
kubectl scale deployment temporal-stack-worker -n temporal --replicas=2
Verifying a scale-out
After scaling, confirm the new pods are running and that the membership ring picked them up:
# tdbg uses internal-frontend (port 7236) to bypass JWT enforcement
kubectl exec -n temporal deployment/temporal-stack-admintools -- \
tdbg --address temporal-stack-internal-frontend:7236 membership list-gossip
This shows every ring member per role with member counts and addresses. After scaling frontend to 3 you should see "member_count": 3 under the frontend role. You can also filter by role:
kubectl exec -n temporal deployment/temporal-stack-admintools -- \
tdbg --address temporal-stack-internal-frontend:7236 membership list-gossip --role frontend
Dual Visibility
This chart supports running two PostgreSQL visibility stores simultaneously — a primary and a secondary. Both stores receive every visibility write in parallel. This gives you a hot standby for visibility queries.
How it works
dualVisibility.enabled is a flag in this chart's values.yaml — it is not a Temporal dynamic config key. Setting it to true instructs the chart to:
- Deploy a third PostgreSQL instance (
postgresql-visibility-secondary) alongside the existingpostgresql-mainandpostgresql-visibilityinstances - Configure the Temporal server with both
visibilityStore(primary) andsecondaryVisibilityStore(secondary) - Seed the Temporal dynamic config key
system.secondaryVisibilityWritingMode: dualinto thetemporal-dynconfigConfigMap — this is what actually activates parallel writes to both stores - Leave reads on the primary store by default (
system.enableReadFromSecondaryVisibility: false)
Enabling dual visibility
In values.yaml:
dualVisibility:
enabled: true
schemaHookEnabled: true
When dualVisibility.enabled: true the following Temporal dynamic config keys are automatically seeded into the temporal-dynconfig ConfigMap at install time:
| Key | Value | Purpose |
|---|---|---|
system.secondaryVisibilityWritingMode | "dual" | Write every visibility record to both stores in parallel |
system.enableReadFromSecondaryVisibility | false | Reads come from primary store (secondary is write-only by default) |
These can be changed live at any time by editing the ConfigMap — no pod restarts required. See Dynamic config for how to do that.
Then run a fresh install:
bash teardown.sh
bash install.sh
The install script detects the flag and automatically:
- Deploys all 3 PostgreSQL instances
- Applies the visibility schema to
postgresql-visibility-secondaryvia a custom Helm hook - Wires
secondaryVisibilityStoreinto the Temporal persistence config - Seeds the dual write dynconfig keys
Verifying dual write is active
# Get a JWT first (auth is required)
JWT=$(go run scripts/dex-login.go)
# Start a test workflow
temporal --address localhost:7233 \
--grpc-meta "authorization=Bearer $JWT" \
workflow start --type MyWorkflow --task-queue my-queue --workflow-id vis-check-1
# Check primary visibility store
kubectl exec -n temporal temporal-stack-postgresql-visibility-0 -- \
sh -c "PGPASSWORD=temporal psql -U temporal -d temporal_visibility \
-c \"SELECT workflow_id, status FROM executions_visibility WHERE workflow_id = 'vis-check-1'\""
# Check secondary visibility store
kubectl exec -n temporal temporal-stack-postgresql-visibility-secondary-0 -- \
sh -c "PGPASSWORD=temporal psql -U temporal -d temporal_visibility_secondary \
-c \"SELECT workflow_id, status FROM executions_visibility WHERE workflow_id = 'vis-check-1'\""
Both queries should return the same row.
You can also confirm both stores are active at the cluster level:
temporal --address localhost:7233 \
--grpc-meta "authorization=Bearer $JWT" \
operator cluster describe
# VisibilityStore column should show: postgres12,postgres12
Known limitation in the upstream Temporal Helm chart
The upstream chart's schema management derives the schema directory name directly from the datastore name. A secondary visibility store named "visibility-secondary" causes it to look for a visibility-secondary/versioned schema path that does not exist in the admintools image — only visibility/versioned exists.
To work around this, this chart sets manageSchema: false on the visibility-secondary datastore and ships a custom Helm hook job (templates/visibility-secondary-schema-job.yaml) that applies the correct visibility/versioned schema to postgresql-visibility-secondary before the Temporal server starts.
Failover: switching reads to secondary during a primary outage
If the primary visibility store degrades, you can switch reads to the secondary live via dynamic config — no restart required:
# 1. Edit the dynconfig ConfigMap
kubectl edit configmap temporal-dynconfig -n temporal
# Set enableReadFromSecondaryVisibility to true:
# system.enableReadFromSecondaryVisibility:
# - value: true
# 2. Verify reads now come from secondary (JWT required)
JWT=$(go run scripts/dex-login.go)
temporal --address localhost:7233 \
--grpc-meta "authorization=Bearer $JWT" \
workflow list --namespace default
When the primary recovers, set system.enableReadFromSecondaryVisibility back to false. The primary will catch up automatically — any writes that arrived during the outage are replayed from the history store into visibility on the next workflow state change or visibility scan.
Important: dual visibility is a last-resort migration tool, not an HA mechanism. The secondary receives no read traffic under normal operation. A primary store failure degrades reads until you manually flip the dynconfig key. For read HA, use Elasticsearch — it handles shard-level failover transparently without any Temporal-level routing.
For a complete end-to-end failure simulation with exact commands and expected output, see Phase 9 — Dual Visibility in TESTING.md.
Authentication
Authentication is enabled by default. The chart bundles Dex as a local OIDC identity provider. The Temporal server uses a custom authorizer and claim mapper (compiled into the temporal-custom-server image) that enforce:
- No JWT → request denied —
authorizer=defaultis active; any call without a valid bearer token is rejected withRequest unauthorizedbefore any handler runs - JWT present → email-based role mapping — the custom claim mapper inspects the JWT
emailclaim and assigns Temporal roles from a static map (see below); nopermissionsclaim in the JWT is required - Optional task queue restrictions — the custom authorizer can block specific task queues from worker polling via the
TEMPORAL_BLOCKED_TASK_QUEUESenv var
How it works
Dex issues JWTs signed with RS256. The Temporal server fetches Dex's JWKS at startup and every 1 minute, then validates all incoming JWTs locally — no network call per request. After signature validation, the custom claim mapper maps the JWT email claim to Temporal roles:
| Roles granted | |
|---|---|
admin@temporal.io | temporal-system:admin, default:admin |
| (any other valid JWT) | default:worker, default:reader |
To change or extend the mapping, edit images/server/auth/claim_mapper.go and rebuild (bash build.sh).
Logging in to the UI
Open http://localhost:30080. You will be redirected to the Dex login page. Use the default static user:
| Password | |
|---|---|
admin@temporal.io | admin |
After login, Dex issues a JWT and redirects back to the UI. The UI attaches the token to all subsequent API calls automatically.
Getting a JWT for SDK workers and CLI
Dex only supports the authorization code flow — it does not support client credentials or password grants. Use the included helper script, which opens a browser tab, handles the PKCE login locally, and prints the tokens:
# Opens a browser tab for Dex login (admin@temporal.io / admin)
# stdout: access_token (use for CLI and --grpc-meta)
# stderr: id_token (use as Bearer token for SDK workers — carries email/permissions claims)
# refresh_token (use to auto-refresh before expiry — no browser needed)
cd temporal-helm-superchart
go run scripts/dex-login.go
For scripts that only need the token for $() capture, stdout is clean:
export TEMPORAL_TOKEN=$(go run scripts/dex-login.go)
The script uses the temporal-cli public Dex client (PKCE, no secret) with a local redirect on localhost:7788.
id_token vs access_token: Temporal's JWT claim mapper reads the
permissionsclaims from theid_token. For SDK workers, pass theid_tokenas the Bearer token. For thetemporalCLI (--auth-token), either token works — the CLI passes it as-is to the server which validates it.
For production IDPs (Okta, Auth0, Keycloak) that support client_credentials, use that flow instead:
JWT=$(curl -s -X POST https://your-idp/oauth2/token \
-d "grant_type=client_credentials&client_id=temporal-worker&client_secret=..." \
| jq -r '.access_token')
For production IDPs (Okta, Auth0, Keycloak) that support client_credentials, use that flow instead:
JWT=$(curl -s -X POST https://your-idp/oauth2/token \
-d "grant_type=client_credentials&client_id=temporal-worker&client_secret=..." \
| jq -r '.access_token')
Using the JWT with the CLI
The temporal CLI uses --grpc-meta to pass the bearer token:
export JWT=$(go run scripts/dex-login.go)
# List namespaces
temporal --address localhost:7233 \
--grpc-meta "authorization=Bearer $JWT" \
operator namespace list
# List workflows
temporal --address localhost:7233 \
--grpc-meta "authorization=Bearer $JWT" \
workflow list --namespace default
# Describe a namespace
temporal --address localhost:7233 \
--grpc-meta "authorization=Bearer $JWT" \
operator namespace describe default
Unauthenticated calls (without --grpc-meta) return Request unauthorized. immediately.
Using the JWT with the Go SDK
Pass the token as a gRPC metadata header using a HeadersProvider. The SDK calls GetHeaders on every outgoing gRPC call, so the provider is the right place to handle token refresh — check expiry inside GetHeaders and re-fetch when needed.
Static token (local testing):
import (
"context"
"go.temporal.io/sdk/client"
)
type bearerToken struct{ token string }
func (b *bearerToken) GetHeaders(ctx context.Context) (map[string]string, error) {
return map[string]string{"authorization": "Bearer " + b.token}, nil
}
c, err := client.Dial(client.Options{
HostPort: "localhost:7233",
Namespace: "default",
HeadersProvider: &bearerToken{token: jwt},
})
Auto-refreshing token (long-running workers):
Implement HeadersProvider to check the JWT exp claim before each call and silently refresh via the IDP's token endpoint when the token is near expiry:
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"sync"
"time"
"go.temporal.io/sdk/client"
)
type tokenProvider struct {
mu sync.Mutex
token string
expiresAt time.Time
refreshToken string
tokenURL string // e.g. "http://host.docker.internal:30556/dex/token"
clientID string // e.g. "temporal-cli"
}
func (p *tokenProvider) GetHeaders(ctx context.Context) (map[string]string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if time.Now().Add(60 * time.Second).After(p.expiresAt) {
if err := p.refresh(); err != nil {
return nil, err
}
}
return map[string]string{"authorization": "Bearer " + p.token}, nil
}
func (p *tokenProvider) refresh() error {
resp, err := http.PostForm(p.tokenURL, url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {p.refreshToken},
"client_id": {p.clientID},
})
if err != nil {
return err
}
defer resp.Body.Close()
var result struct {
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
p.token = result.IDToken
if result.RefreshToken != "" {
p.refreshToken = result.RefreshToken // Dex may rotate the refresh token
}
p.expiresAt = jwtExpiry(result.IDToken)
return nil
}
// jwtExpiry decodes the JWT payload (no signature verification — server does that)
// and returns the exp claim as a time.Time.
func jwtExpiry(token string) time.Time {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Now().Add(time.Hour)
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Now().Add(time.Hour)
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 {
return time.Now().Add(time.Hour)
}
return time.Unix(claims.Exp, 0)
}
Token source:
go run scripts/dex-login.goprints both theid_tokenandrefresh_token. Pass both totokenProviderat startup. With Dex's default 24 h token lifetime and 1 minute refresh window, workers never need restarting.
Using the JWT with the Java SDK
Use AuthorizationGrpcMetadataProvider with an AuthorizationTokenSupplier — not addGrpcClientInterceptor. The metadata provider plugs into the SDK's innermost interceptor (GrpcMetadataProviderInterceptor) and covers all calls including internal ones like GetSystemInfo. Using addGrpcClientInterceptor places the interceptor before the SDK's SystemInfoInterceptor in the chain, so the auth header is missing from internal bootstrap calls and the connection fails immediately with PERMISSION_DENIED.
Static token (local testing):
import io.temporal.authorization.AuthorizationGrpcMetadataProvider;
import io.temporal.authorization.AuthorizationTokenSupplier;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.serviceclient.WorkflowServiceStubsOptions;
String token = System.getenv("TEMPORAL_TOKEN");
WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs(
WorkflowServiceStubsOptions.newBuilder()
.setTarget("localhost:7233")
.addGrpcMetadataProvider(new AuthorizationGrpcMetadataProvider(
() -> "Bearer " + token))
.build());
Auto-refreshing token (long-running workers):
AuthorizationTokenSupplier is a @FunctionalInterface called on every gRPC call. Implement it to check the JWT exp claim and refresh via Dex's token endpoint when the token is near expiry:
import io.temporal.authorization.AuthorizationGrpcMetadataProvider;
import io.temporal.authorization.AuthorizationTokenSupplier;
public class DexTokenSupplier implements AuthorizationTokenSupplier {
private volatile String cachedToken;
private volatile Instant expiresAt;
private final String refreshToken;
private final String tokenUrl; // "http://host.docker.internal:30556/dex/token"
private final String clientId; // "temporal-cli"
public DexTokenSupplier(String initialToken, String refreshToken,
String tokenUrl, String clientId) {
this.cachedToken = initialToken;
this.expiresAt = parseExpiry(initialToken);
this.refreshToken = refreshToken;
this.tokenUrl = tokenUrl;
this.clientId = clientId;
}
@Override
public synchronized String supply() {
if (Instant.now().isAfter(expiresAt.minusSeconds(60))) {
refresh();
}
return "Bearer " + cachedToken;
}
private void refresh() {
// POST grant_type=refresh_token to Dex token endpoint, parse id_token
// update cachedToken and expiresAt
}
private Instant parseExpiry(String jwt) {
// base64-decode the middle section, read the "exp" claim
String payload = jwt.split("\\.")[1];
byte[] decoded = Base64.getUrlDecoder().decode(payload);
// parse JSON, extract "exp" as epoch seconds
// return Instant.ofEpochSecond(exp)
}
}
// Wire it in:
WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs(
WorkflowServiceStubsOptions.newBuilder()
.setTarget("localhost:7233")
.addGrpcMetadataProvider(new AuthorizationGrpcMetadataProvider(
new DexTokenSupplier(idToken, refreshToken,
"http://host.docker.internal:30556/dex/token", "temporal-cli")))
.build());
Token source:
go run scripts/dex-login.goprints theid_tokenandrefresh_token. Pass both at startup. Thesupply()method is called per-gRPC-call; token rotation happens transparently without restarting the worker.
Namespace and role restrictions
The custom claim mapper grants roles based on email. The mapping is in images/server/auth/claim_mapper.go:
| Role | What it allows |
|---|---|
reader | List/describe workflows, get history — read-only |
writer | Start, signal, cancel, terminate workflows + everything reader can do |
worker | Poll task queues, heartbeat, complete tasks — nothing else |
admin | Everything including UpdateNamespace, RegisterNamespace, DeprecateNamespace |
To add more users or change permissions, edit the emailPermissions map and rebuild:
// images/server/auth/claim_mapper.go
var emailPermissions = map[string][]string{
"admin@temporal.io": {"temporal-system:admin", "default:admin"},
"dev@example.com": {"default:writer"},
"reader@example.com": {"default:reader"},
}
For production IDPs (Okta, Auth0, Keycloak) that issue JWTs with a permissions claim, the custom claim mapper falls back to the default claim mapper path — email mapping is only used when permissions is absent.
Task queue restrictions
The custom authorizer can block specific task queues from worker polling. Set the TEMPORAL_BLOCKED_TASK_QUEUES env var on the server pods (comma-separated):
# Block a task queue — workers that try to poll it get PermissionDenied
kubectl set env deployment/temporal-stack-frontend \
TEMPORAL_BLOCKED_TASK_QUEUES=InternalQueue,AdminOnlyQueue -n temporal
kubectl set env deployment/temporal-stack-history \
TEMPORAL_BLOCKED_TASK_QUEUES=InternalQueue,AdminOnlyQueue -n temporal
kubectl set env deployment/temporal-stack-matching \
TEMPORAL_BLOCKED_TASK_QUEUES=InternalQueue,AdminOnlyQueue -n temporal
# Remove the restriction
kubectl set env deployment/temporal-stack-frontend TEMPORAL_BLOCKED_TASK_QUEUES- -n temporal
kubectl set env deployment/temporal-stack-history TEMPORAL_BLOCKED_TASK_QUEUES- -n temporal
kubectl set env deployment/temporal-stack-matching TEMPORAL_BLOCKED_TASK_QUEUES- -n temporal
Only PollWorkflowTaskQueue and PollActivityTaskQueue are blocked — other operations on that namespace/task queue (list, describe, start workflow) are unaffected.
Production: using an external IDP
To use Okta, Auth0, Keycloak, or any OIDC-compliant IDP instead of the bundled Dex:
# values.yaml
auth:
enabled: true
jwt:
keySourceURIs:
- https://your-okta-domain/oauth2/default/v1/keys
ui:
providerUrl: https://your-okta-domain/oauth2/default
clientId: your-okta-client-id
callbackUrl: https://your-temporal-ui/auth/sso/callback
dex:
enabled: false # disable bundled Dex
Production IDPs typically issue JWTs with a permissions claim. The custom claim mapper detects this and routes to the default mapper path automatically — no code change needed.
Disabling authentication (local dev only)
Not recommended — but if you need a no-auth cluster for quick local testing, set auth.enabled: false and dex.enabled: false in values.yaml and reinstall. The server will use authorizer=noop and accept all connections without tokens.
Useful Kubernetes Commands
These are handy commands for exploring and understanding your local cluster.
Cluster overview
# See all nodes and their status
kubectl get nodes
# See all namespaces
kubectl get namespaces
# See all pods across every namespace
kubectl get pods -A
# See what StorageClasses are available
kubectl get storageclass
Working with namespaces
# See everything running in the temporal namespace
kubectl get all -n temporal
# See all pods in the temporal namespace
kubectl get pods -n temporal
# Watch pods in real time (updates live)
kubectl get pods -n temporal -w
# See pods with more detail (node, IP, etc.)
kubectl get pods -n temporal -o wide
Inspecting pods
# Describe a pod (events, resource limits, mounts — useful for debugging)
kubectl describe pod <pod-name> -n temporal
# Stream logs from a pod
kubectl logs -f <pod-name> -n temporal
# Stream logs from a specific container inside a pod
kubectl logs -f <pod-name> -c <container-name> -n temporal
# Get a shell inside a running pod
kubectl exec -it <pod-name> -n temporal -- /bin/sh
Helm
# List all installed Helm releases
helm list -A
# Show the current values for an installed release
helm get values temporal-stack -n temporal
# Show all computed values (including defaults)
helm get values temporal-stack -n temporal --all
# Check the status of a release
helm status temporal-stack -n temporal
Cleanup
For a full teardown use teardown.sh — it handles the dynconfig ConfigMap and images correctly. See Uninstalling / Starting Fresh.
Troubleshooting
Pods stuck in Pending:
kubectl describe pod <pod-name> -n temporal
Usually a resource constraint or PVC not binding. Check that your Docker Desktop has enough memory allocated.
Images not found:
Build the custom images using build.sh — do not use docker build directly, as build.sh handles the server checkout and go.mod alignment:
bash build.sh --server-version v1.31.0
Port already in use: If port 7233 is in use, your Docker Compose cluster may still be running. Stop it first:
docker compose down
Request unauthorized from CLI or SDK:
Auth is enabled by default. All calls to port 7233 require a JWT bearer token. Get one with:
JWT=$(go run scripts/dex-login.go)
temporal --address localhost:7233 --grpc-meta "authorization=Bearer $JWT" operator namespace list
See Authentication for the full CLI and SDK usage.
Dex login page shows a DNS error (host.docker.internal not found):
Docker Desktop sometimes omits host.docker.internal from /etc/hosts. Fix:
echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts
Then reload the browser. This is a one-time fix.
Browser redirects to Dex but shows oidc: issuer did not match or invalid_client:
The Dex issuer URL must match exactly between the server config, the UI env var, and the browser. If you changed the Dex issuer in values.yaml, run a full teardown and reinstall — a helm upgrade alone does not update the Temporal server's JWKS URI.
UI shows 403 / redirect loop after Dex login:
The server received a valid JWT but the claim mapper found no recognised email and granted no roles. Check that the JWT email matches an entry in emailPermissions in images/server/auth/claim_mapper.go, or that the JWT contains a permissions claim if using an external IDP.
UI loops back to login after Dex login succeeds (SSO callback loop): Two possible causes:
- Dex restarted — Dex stores signing keys in memory (
storage: type: memory). Any pod restart (including afterhelm upgrade) generates new keys. The browser has a cookie with a JWT signed by the old key, which the Temporal server now rejects withRSA key not found for key ID: .... Fix: clear browser cookies/session forlocalhost:30080and log in again. For SDK workers, rungo run scripts/dex-login.goto get a new token. - Missing JWKS URI — The Temporal server has no
keySourceURIsconfigured and cannot validate any JWT. Check:helm get values temporal-stack -n temporal | grep -A5 authorization. If empty, theauthorization:block is missing fromvalues.yamlundertemporal.server.config. Fix: ensureauthorization:is inside the sameconfig:block aspersistence:(not a separate siblingconfig:block — YAML silently drops duplicate keys).
tdbg returns Request unauthorized:
tdbg does not support passing a JWT. Use internal-frontend (port 7236) which bypasses JWT enforcement:
kubectl exec -n temporal deployment/temporal-stack-admintools -- \
tdbg --address temporal-stack-internal-frontend:7236 <subcommand>
Upgrading Temporal Server
See UPGRADE.md for the full step-by-step upgrade runbook, including schema migration, binary rollout, and verification.