Skip to main content

Configuration Reference

Complete reference for Helm values configuration.

Global Settings​

global:
# Environment: development, staging, production
environment: production

# Image registry prefix. Required: the chart refuses to render without it.
imageRegistry: "your-registry.example.com"

# One release for the gateway, migrations, brain and UI. Unset, they resolve to "latest".
imageTag: "v1.2.3"

# Image pull secrets for private registries
imagePullSecrets:
- name: regcred

# RDS SSL configuration (for AWS RDS)
rdsSSL:
enabled: true

Proxy & Network​

See the dedicated Network & Proxy Configuration page for full details.

global:
proxy:
httpsProxy: "http://proxy.corp.example.com:3128"
httpProxy: ""
noProxy: "*.internal.corp.com"

networkPolicies:
enabled: false
allowedExternalCIDRs: []
allowDNS: true

Connector API Base URL Overrides​

connectors:
slack:
apiBaseUrl: "" # Override Slack API base URL
teams:
graphApiBaseUrl: "" # Override Microsoft Graph API base URL
jira:
apiBaseUrl: "" # Override Jira/Atlassian API base URL
github:
apiBaseUrl: "" # Override GitHub API base URL (or GitHub Enterprise)
linear:
apiBaseUrl: "" # Override Linear API base URL

Database​

database:
# Name of the Kubernetes secret containing database credentials
secretName: align-database

# Secret should contain:
# url: postgresql://user:pass@host:port/dbname
# host: hostname (for health checks)
# port: 5432
# username: database user
# password: database password

Connection Pooling​

Align's historical scan feature (Discover) processes items in parallel, which can put pressure on database connections. We provide several options depending on your infrastructure.

The Helm chart includes an optional PgBouncer sidecar that provides enterprise-grade connection pooling on any cloud provider (AWS, GCP, Azure, on-premises):

  • Cost: Free (runs as a sidecar container)
  • Benefits: Connection multiplexing (1000 app connections → 50 DB connections), works everywhere
  • Configuration: Enable in your values override
gateway:
pgbouncer:
enabled: true
# Pool mode: transaction (recommended), session, or statement
poolMode: "transaction"
# Maximum connections from the gateway to PgBouncer
maxClientConnections: 1000
# Connections PgBouncer maintains to PostgreSQL
defaultPoolSize: 50
minPoolSize: 10
reservePoolSize: 5
extraEnv:
# With PgBouncer, you can use higher concurrency
- name: SQS_IMPORT_WORKER_CONCURRENCY
value: "30"

When enabled, the gateway automatically routes database connections through the PgBouncer sidecar running on localhost:6432.

Option 2: RDS Proxy (AWS)​

If you're running on AWS, RDS Proxy is a managed alternative:

  • Cost: ~$22/month
  • Benefits: Connection multiplexing, automatic failover, IAM authentication
  • Configuration: Point DATABASE_URL to the proxy endpoint instead of RDS directly

Setup steps:

  1. Create RDS Proxy in AWS (via Console, CLI, or Terraform):

    • Target your PostgreSQL RDS instance
    • Create an IAM role with Secrets Manager access
    • Configure security group to allow traffic from your EKS cluster
  2. Update your database secret to use the proxy endpoint:

    kubectl create secret generic align-database \
    --namespace align \
    --from-literal=url="postgresql://user:pass@my-proxy.proxy-xxx.us-east-1.rds.amazonaws.com:5432/align?sslmode=require" \
    --from-literal=host="my-proxy.proxy-xxx.us-east-1.rds.amazonaws.com" \
    --from-literal=port="5432" \
    --from-literal=username="user" \
    --from-literal=password="pass" \
    --from-literal=database="align"
  3. Helm values - PgBouncer is disabled by default, so no changes needed:

    gateway:
    pgbouncer:
    enabled: false # Default, no sidecar needed with RDS Proxy
    extraEnv:
    # With RDS Proxy, you can use higher concurrency
    - name: SQS_IMPORT_WORKER_CONCURRENCY
    value: "30"
    # App pool is just for local buffering
    - name: PG_POOL_MAX
    value: "10"

RDS Proxy is transparent to the application - you simply point DATABASE_URL to the proxy endpoint instead of the direct RDS endpoint.

Option 3: Application-Level Pooling (Fallback)​

Without connection pooling, Align includes automatic retry with exponential backoff for connection pool exhaustion:

gateway:
pgbouncer:
enabled: false
extraEnv:
# Keep workers <= pool_size * num_replicas / 2
# Example: 2 replicas, pool=15 → workers=15 max
- name: SQS_IMPORT_WORKER_CONCURRENCY
value: "15"
# Size based on your PostgreSQL max_connections
- name: PG_POOL_MAX
value: "15"

Tuning guidelines (without pooling):

  • Check your PostgreSQL max_connections (typically 100-200)
  • Reserve ~20 connections for admin/migrations
  • Divide remaining by number of services (gateway + brain)
  • Set PG_POOL_MAX to that value per service
  • Set SQS_IMPORT_WORKER_CONCURRENCY to PG_POOL_MAX * num_replicas / 2

Tenant Isolation: the restricted align_app role​

Align enforces tenant isolation at two layers: the application scopes every query to one tenant (withTenant), and PostgreSQL Row-Level Security filters every row by tenant_id. RLS only bites when the app connects as a non-owner role, which is what align_app is.

A new install is walked through this as a documented step right after migrations run - it is not automatic, you set the role's password and add it to the database secret yourself - see Installation. An existing install switches with the runbook at RLS Cutover, which is also where the verification query and the RDS Proxy caveats live.

Data Retention​

Align is built to store decisions, not your raw messages. The decision record itself keeps only the distilled decision plus a source_url linking back to the original in your own tool. Some captured content does persist elsewhere for a time, so the full picture is:

  • Live capture (real-time / @mention / webhook) is process-then-discard: the original text is written with the snapshot, then cleared once analysis completes. Note this clear runs as a background task without retry, so a process restart mid-flight can leave raw text on that row.
  • Discover / historical import suggestions never store raw content. The import_suggestions.raw_content column is always written empty and a database CHECK constraint (import_suggestions_raw_content_empty) enforces it - the schema itself proves it, so it is safe to audit.
  • Import jobs are purged on a schedule. cleanup_old_import_jobs() deletes import jobs, batches, and suggestions for completed/failed jobs older than 33 days (run by the gateway's data-cleanup scheduler). Until that runs, import_job_batches.items_json holds the full fetched content, including meeting transcripts, so imports survive a pod restart.
  • The connector capture buffer (sources) stores provenance only - tenant, platform, source reference and timestamp. A database CHECK constraint enforces that its payload column is always empty, so captured content cannot be persisted there. See Data Retention.
  • The optional S3 raw-event archive is off unless you set S3_RAW_EVENTS_BUCKET. When enabled it writes raw fetched items to a bucket you own, with no lifecycle policy applied by Align - set an expiry rule yourself.

The retained decision content lives in decision_snapshots. In the managed deployment this sits on RDS with at-rest encryption (KMS-backed); in a self-host deployment, ensure your PostgreSQL storage is encrypted at rest according to your own infrastructure standards.

Gateway (API Server)​

gateway:
enabled: true
# One by default. Above one, a shared session store is mandatory and the chart refuses to
# render without it - see the `redis` block below.
replicaCount: 1

# The web app URL. Decision links, redirects, the share approval link, and the passkey
# relying party. See "Share approval" below.
frontendUrl: "https://app.yourdomain.com"

# Browser approval for `align share`: available (default), required or off.
shareApprovalMode: available

image:
repository: align/gateway
tag: "" # empty follows global.imageTag
pullPolicy: IfNotPresent

resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"

service:
type: ClusterIP
port: 8080

# Autoscaling (off by default)
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70

# Pod Disruption Budget
pdb:
enabled: true
minAvailable: 1

# Ingress
ingress:
enabled: true
className: "nginx" # or "traefik"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: api.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: api-tls
hosts:
- api.yourdomain.com

# Redis for job state, real-time progress, and SESSION REVOCATION.
# REQUIRED for more than one gateway replica: the chart refuses to render replicaCount > 1
# (or an HPA with maxReplicas > 1) with neither value set. Without a shared store each pod
# holds its own revocation markers, so a revoked session stays live on every pod that did
# not serve the revoke call, and no marker survives a pod restart.
# For single-pod deployments, leave empty to use in-memory storage.
# Works with any Redis-compatible service: ElastiCache, Memorystore, Azure Cache, Dragonfly, KeyDB
redis:
# Direct URL (e.g., redis://host:6379 or rediss://user:pass@host:6379)
url: ""
# Or use a Kubernetes secret:
secretName: ""
secretKey: "url"

# Job queue (ALI-1929). Empty provider = auto: SQS if the URLs below are set, else Redis
# (the gateway.redis block above), else the gateway refuses to start in production.
# sqs | redis force one. memory is allowed only when named: queued work is lost on restart.
queue:
provider: ""
sqs:
# Queue for Discover historical scans
importJobQueueUrl: ""
# Queue for bulk approval with cross-linkage
bulkApprovalQueueUrl: ""
# Queue for bulk delete operations
bulkDeleteQueueUrl: ""

# PgBouncer sidecar for connection pooling (recommended for self-hosted)
# See "Connection Pooling" section above for details
pgbouncer:
enabled: false
image:
repository: bitnami/pgbouncer
tag: "1.23.1"
poolMode: "transaction"
maxClientConnections: 1000
defaultPoolSize: 50
minPoolSize: 10
reservePoolSize: 5
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"

# Single sign-on is off by default. See "OIDC Authentication" below.

# Additional environment variables
extraEnv:
- name: LOG_LEVEL
value: "info"

Share approval (align share)​

align share stages a set of decisions and asks a person to approve them before anything leaves their machine. Two gateway settings control this. Both are Helm values, and both reach the gateway pod as environment variables.

Helm valueEnvironment variableMeaning
gateway.shareApprovalModeSHARE_APPROVAL_MODEavailable, required or off. Default available. Any other value fails the render.
gateway.frontendUrlFRONTEND_URLThe URL your users type to open the web app.

available offers approval in the web app. It does not force it. The person can still approve with a typed answer at their terminal.

required makes approval in the web app the only way. An API token or CLI token can stage a share but cannot approve it, so an agent holding a token cannot approve its own share.

required is not a lock. An agent that can read a person's mailbox or browser cookies can still get a signed-in session and approve.

off turns browser approval off and leaves the typed answer.

FRONTEND_URL does three jobs here, so set it to the real address:

  1. The web app URL. The gateway returns it as app_url from GET /share-requests/config (an origin only, omitted when FRONTEND_URL is unset or is not an http or https URL). A client can build the approval link from it instead of guessing from the gateway hostname.
  2. The browser origin check. Approve and decline refuse any request whose Origin is not this URL's origin. With FRONTEND_URL unset they refuse everything.
  3. The passkey relying party, when passkey approval ships. The relying party ID will be the hostname of this URL, so it must be https, or http on localhost only. An IP address will not work. Changing the hostname later invalidates every enrolled passkey.

The gateway also builds sign-in redirects, the session cookie base, magic links and the logo in sign-in emails from FRONTEND_URL. It never substitutes a hosted address. In production (NODE_ENV=production, which the gateway image sets) it refuses to start when FRONTEND_URL is unset or is not an http or https URL, and the error names the variable. Outside production it logs a warning and starts. The email logo is left out and the passkey notices describe the settings page in words when there is no usable value.

The chart renders http://align.local when gateway.frontendUrl is empty and there is no ui.ingress host to derive from. The gateway cannot tell that this is a placeholder, so it advertises it anyway. Set gateway.frontendUrl.

Do not also set SHARE_APPROVAL_MODE in gateway.extraEnv. The chart refuses the pair.

Unprovisioned email domains​

A person who signs in for the first time with a work email, on a domain that has no workspace yet, is refused. Their sign-in does not create one. By default they see:

Your organisation has not set up this workspace for your email domain yet. Ask your workspace administrator to add you.

To name your own contact instead, set gateway.signupClosedMessage. It reaches the gateway pod as SIGNUP_CLOSED_MESSAGE.

gateway:
signupClosedMessage: "Ask the IT service desk (ext 42) to add you."

The text is plain: 300 characters at most, with no < or >, no line breaks and no invisible characters. A value that breaks those rules fails the render. It is shown to people who are not signed in, so do not put anything private in it.

Schema Version Guard​

The gateway refuses to start (and /ready returns 503) if the database schema is behind the migrations this gateway build requires. This turns the "app ahead of database" drift into a loud, immediate failure instead of silent write corruption. See Upgrades → Schema Version Guard for the runbook.

VariableDefaultDescription
SCHEMA_CHECK_MODEenforceenforce = refuse to start / 503 when the DB is behind; warn = log and start anyway; off = disable the check. Keep enforce in production.
gateway:
extraEnv:
- name: SCHEMA_CHECK_MODE
value: "enforce"
Keep the migrations and gateway image tags in lockstep

The guard catches the case where the migrations image tag lags the gateway image tag. To avoid tripping it on every upgrade, set both image tags to the same release in your Helm values.

Discover (Historical Scan) Tuning​

The Discover feature scans your connected platforms for historical decisions. It uses platform-specific batch sizes and confidence thresholds optimized for each connector:

PlatformBatch SizeMin ConfidenceNotes
Slack50.20Smaller batches
Teams80.20Chat-based items
GitHub80.33Medium batches - PRs/issues with comments
Jira80.33Medium batches - structured ticket content
Confluence80.33Medium batches - document-style content

These defaults are tuned for optimal detection quality and speed. You can override them with environment variables:

gateway:
extraEnv:
# Override minimum confidence threshold for all platforms (default: platform-specific)
# Items below this threshold are filtered out. Lower values catch more
# borderline decisions for human review. Range: 0.0-1.0
- name: IMPORT_MIN_CONFIDENCE
value: "0.2"

# Confidence a Discover suggestion needs to appear in "Tier review", where a
# person accepts the whole group in one action (default: 0.85). Range: 0.5-1.0.
# That is a person accepting a batch. It is not a review of each suggestion.
# Tier review is on for a tenant with no row in `one_click_lane`, so a new workspace starts
# with it on. An org admin can switch it off (PUT /import/one-click-lane). Tenants that
# existed when migration 298 ran keep the value they had: migration 260 switched on the ones
# that existed then, and 298 pinned the rest off. Until a gateway pod runs the new image, it
# reads no row as off. A connector is still only offered after 20 labels at 93%.
# To switch one off by hand, run in one psql session as the table owner (the table forces row
# level security, so set the tenant first):
# SELECT set_config('app.current_tenant', '<tenant-uuid>', false);
# INSERT INTO one_click_lane (tenant_id, enabled) VALUES ('<tenant-uuid>', false)
# ON CONFLICT (tenant_id) DO UPDATE SET enabled = false, updated_at = now();
# Anything invalid or out of range is ignored and the default is used.
# The other two conditions always apply and are not configurable: the source
# must name a real reference (Jira key, PR, settled Confluence status, closed
# Linear issue), and the suggestion must not be a fallback guess.
- name: IMPORT_AUTO_TIER_MIN_CONFIDENCE
value: "0.85"

# Worker concurrency - number of parallel batch processors (default: 12 in-memory, 20 SQS)
# Higher = faster scans but more database connections consumed.
# Must be < PG_POOL_MAX to leave headroom for API requests.
- name: SQS_IMPORT_WORKER_CONCURRENCY
value: "20"

# Application connection pool size per gateway replica (default: 15)
# Total DB connections = PG_POOL_MAX × number of gateway replicas
# Ensure this fits within your PostgreSQL max_connections
- name: PG_POOL_MAX
value: "25"

# Database statement timeout in milliseconds (default: 120000 = 2 min)
# Increase for large tenant scans that process thousands of items
- name: PG_STATEMENT_TIMEOUT
value: "300000"

# --- Parallelism Settings ---
# These control concurrent API calls to connectors during scanning.
# Higher values = faster scans but more API rate limit pressure.

# Parallel Slack channels to scan simultaneously (default: 10)
- name: IMPORT_PARALLEL_CHANNELS
value: "10"

# Parallel thread fetches for Slack/Teams (default: 20)
- name: IMPORT_PARALLEL_THREADS
value: "20"

# Parallel comment fetches for Jira/GitHub (default: 20)
- name: IMPORT_PARALLEL_COMMENTS
value: "20"

# Deployment-level ambient-capture kill switch (ALI-301), independent of licensing.
# Ambient capture (a completed Zoom recording, a merged GitHub PR, a channel reaction)
# auto-creates decisions with no per-item human confirmation; per-resource opt-in
# (`ambient_detection_enabled`, default off) already exists, but a completed recording
# and a merged PR bypass it by design (a merge is not a capture request). This switch is
# the operator-level backstop for those two paths.
# Unset (default) = today's behaviour, unchanged. Set to one of on/1/true/yes to
# explicitly permit ambient capture; anything else, including a typo, disables it - an
# operator who sets this at all is expressing intent to restrict, so an unrecognised
# value fails CLOSED rather than silently permitting.
# Subtractive only: this can withhold ambient capture the license grants, and can never
# grant a feature the license withholds (see licensing.md).
# Left commented out (rather than set to "false") so copying this reference block
# does not silently disable ambient capture for installs that wanted to keep it on.
# - name: ALIGN_AMBIENT_CAPTURE
# value: "false"

# Parallel meeting fetches for Teams (default: 10)
- name: IMPORT_PARALLEL_MEETINGS
value: "10"

# Maximum parallel GitHub repos to scan simultaneously (default: 10)
# Higher values speed up GitHub scans but increase API rate limit pressure
- name: IMPORT_PARALLEL_REPOS
value: "10"

# Parallel GitHub detail fetches per repo (default: 20)
# Controls concurrent issue/PR detail API calls within each repo
- name: IMPORT_PARALLEL_GITHUB_DETAILS
value: "20"

Performance tips:

  • Single-pod deployments: Use SQS_IMPORT_WORKER_CONCURRENCY=12 (default) and PG_POOL_MAX=15
  • Multi-pod with Redis: Use SQS_IMPORT_WORKER_CONCURRENCY=20-30 and PG_POOL_MAX=25-40
  • With PgBouncer: You can safely increase SQS_IMPORT_WORKER_CONCURRENCY=30+ since PgBouncer handles connection multiplexing
  • Large organizations (1000+ items): Increase SQS_IMPORT_WORKER_CONCURRENCY and add Brain replicas for more parallel LLM analysis
  • Slack and Teams benefit most from lower confidence thresholds, as thread-based discussions often contain implicit decisions
  • The Brain service dynamically scales its LLM token budget based on batch size, so no tuning is needed on the Brain side
IMPORT_BATCH_SIZE is now a global override

Batch sizes are now platform-specific and optimized automatically (Slack: 5, Teams, GitHub and Jira: 8). If IMPORT_BATCH_SIZE is set, it acts as a global override for all platforms. In most cases you can safely remove this variable; if you keep it, it will override all platform-specific defaults.

Automated-message filtering (Slack)​

Align drops automated Slack posts before they reach decision extraction, on both paths that ingest Slack content: Discover scans (gateway) and ambient capture plus @Align analysis (the Slack connector).

Two independent layers, both on by default:

VariableDefaultDropsSet on
INGEST_DROP_BOT_MESSAGESonPosts Slack attributes to a bot or app (bot_id, subtype: bot_message, app_id)gateway and slack connector
INGEST_DROP_MACHINE_NOTIFICATIONSonTemplated build/test/deploy output, whatever account posted itgateway only

Set either to false to turn that layer off. Any other value leaves it on, so a typo cannot silently disable filtering.

To disable bot filtering everywhere, add INGEST_DROP_BOT_MESSAGES=false to BOTH the gateway and the Slack connector. They are separate processes with separate ingestion paths, and each reads its own copy of the variable. Adding it to only one of them leaves the other still dropping bot messages, so bot content would appear from Discover scans but not ambient capture, or the reverse.

gateway:
extraEnv:
# Your release bot posts genuine decisions - keep them.
- name: INGEST_DROP_BOT_MESSAGES
value: "false"
connectors:
slack:
extraEnv:
- name: INGEST_DROP_BOT_MESSAGES
value: "false"

The machine-shape layer is gateway-only on purpose. A Discover scan analyses one Slack message at a time, so a build notification is the whole item and recognising its shape matters. Ambient capture analyses a thread, where a relayed CI post sits among real discussion that the model reads perfectly well once the bot messages are gone.

Neither layer drops a message that has replies. Replies are fetched from the thread root, so dropping the root would lose the discussion under it. Slack's reply_count does not say who replied, so a bot thread that only bots replied in is kept too - the exemption is deliberately wider than strictly needed, because losing a real discussion is the more expensive mistake.

Why this is on by default: on our own measured corpus of 183 hand-labelled decision pairs, 55 were pairs of CI notifications that had been ingested as decisions, and they accounted for 55 of the 70 false conflicts the deterministic classifier raised (re-measured 2026-08-12, when the corpus gained the decision-statement text the classifier reads; the first measurement was 55 of 58). Turning a layer off is reasonable if your bots post real decisions; expect more noise in conflict detection if they do not.

OIDC Authentication​

Self-hosted deployments can use their own identity provider for single sign-on (SSO). Align supports any OIDC-compliant provider:

  • Azure AD / Entra ID
  • Okta
  • Auth0
  • Keycloak
  • Google Workspace

Setup Steps​

  1. Create an OAuth application in your identity provider:

    • Set the redirect URI to https://api.yourdomain.com/auth/callback
    • Enable the openid, email, and profile scopes
    • Note your Client ID and Client Secret
  2. Create the OIDC secret:

    kubectl create secret generic align-oidc \
    --namespace align \
    --from-literal=issuer-url="https://login.microsoftonline.com/{tenant-id}/v2.0" \
    --from-literal=client-id="your-client-id" \
    --from-literal=client-secret="your-client-secret"
  3. Enable OIDC in your values:

    gateway:
    oidc:
    enabled: true
    scopes: "openid email profile"
    defaultTenantId: "your-align-tenant-uuid" # For single-tenant deployments
  4. Deploy with helm upgrade

In production the gateway starts with a working OIDC config, so the Cognito placeholder secret is only needed without OIDC. See Installation, Step 3.

Provider-Specific Configuration​

Azure AD / Entra ID

Issuer URL: https://login.microsoftonline.com/{tenant-id}/v2.0

  1. Go to Azure Portal → Azure Active Directory → App registrations
  2. Create a new registration
  3. Add a redirect URI: https://api.yourdomain.com/auth/callback
  4. Generate a client secret
  5. Grant openid, email, and profile permissions
Okta

Issuer URL: https://{your-domain}.okta.com

  1. Create a new OIDC Web Application in Okta Admin
  2. Set the redirect URI: https://api.yourdomain.com/auth/callback
  3. Note the Client ID and Client Secret
Keycloak

Issuer URL: https://{host}/realms/{realm}

  1. Create a new client in your realm
  2. Set Access Type to "confidential"
  3. Add the redirect URI: https://api.yourdomain.com/auth/callback
  4. Enable "Standard Flow"

Map directory groups to roles​

By default every SSO/SCIM-provisioned user is created as a member. To grant org_admin or viewer from your identity provider's directory groups, configure a group -> role mapping. This works across OIDC login, SAML login, and SCIM provisioning.

Valid roles: org_admin, member, viewer.

VariableApplies toPurpose
SSO_GROUP_ROLE_MAPPINGOIDC + SAMLJSON object mapping a directory group name to an Align role
SSO_GROUPS_CLAIMOIDCToken claim that lists the user's groups (default groups)
SAML_GROUPS_ATTRIBUTESAMLAssertion attribute that lists the user's groups (default groups)
SAML_DEFAULT_TENANT_IDSAMLTenant uuid the sign-in page offers SAML for. Unset: no SAML button (see SAML sign-in)
# Example: members of "Eng-Admins" become org_admin, "ReadOnly" become viewer.
SSO_GROUP_ROLE_MAPPING='{"Eng-Admins":"org_admin","ReadOnly":"viewer"}'
SSO_GROUPS_CLAIM=groups # OIDC: ensure your IdP emits a groups claim in the ID token
SAML_GROUPS_ATTRIBUTE=groups # SAML: the attribute name in the assertion

For SCIM, set the role per group via the SCIM Group API using the alignRole attribute on create (POST /scim/v2/Groups) or patch (op: replace, path: alignRole). A user's effective role is the most privileged role across all the groups they belong to.

Behavior notes:

  • Most privileged wins. A user in both an admin group and a viewer group becomes org_admin.
  • Re-synced on every login. Removing a user from the admin group in your IdP demotes them on their next login (directory-driven access control).
  • Manual overrides are sticky. If an org admin changes a user's role in the Align UI, that role is marked manual and directory sync will not overwrite it.
  • Fail-open. Invalid mapping JSON is ignored (logged as a warning), and a login where no group matches keeps the user's existing role - directory sync never locks anyone out. The last active org_admin of a tenant is never demoted by login sync.

Set the mapping with chart values. They render the variables above, and an empty value renders nothing:

gateway:
sso:
groupRoleMapping: '{"Eng-Admins":"org_admin","ReadOnly":"viewer"}' # SSO_GROUP_ROLE_MAPPING
groupsClaim: groups # SSO_GROUPS_CLAIM
groupFunctionMapping: "" # SSO_GROUP_FUNCTION_MAPPING

Setting the same variable in gateway.extraEnv as well fails the render, because extraEnv renders last and would silently win.

SCIM provisioning​

SCIM needs no chart setting and no environment variable. The routes are always on, and the default gateway ingress (/) already reaches them. The identity provider authenticates with a bearer token that an org admin mints in the app. Without a valid token every /scim/v2 call returns 401, so an unconfigured install provisions nobody.

  1. Signed in as an org admin (an interactive login, not an API key), create a token: POST /admin/scim-tokens with {"expires_in_days": 90} (1 to 365). The response shows the token once. You can hold 5 active tokens, and you can rotate or revoke each one (POST /admin/scim-tokens/:id/rotate, DELETE /admin/scim-tokens/:id).
  2. In your identity provider, set the SCIM base URL to https://<your-api-host>/scim/v2 and the bearer token to the value from step 1.
  3. Assign users and groups. Align supports Users and Groups (create, read, patch, delete).

What happens:

  • Create adds the user as a member. Adding them to a group then sets their role.
  • Group to role. Set the alignRole attribute on the group (see above). The most privileged role across a person's groups applies.
  • Deactivate (active: false, or delete) ends the user's sessions as well as marking them inactive. If the sessions cannot be ended, the call fails and the identity provider retries. Align refuses to deactivate the last active org_admin.
  • Audit. Each SCIM change, and each token create, rotate and revoke, is written to the audit log (scim:user_created, scim:user_active_changed, scim:group_created, scim:group_updated, scim:group_deleted, scim:group_space_mapped, scim:token_created, scim:token_rotated, scim:token_revoked).

This is checked by the gateway's integration tests. It has not been run against a live Okta or Entra tenant on a Helm install.

Brain (AI Service)​

brain:
enabled: true
replicaCount: 2

image:
repository: align/brain
tag: "" # empty follows global.imageTag

resources:
requests:
memory: "512Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"

service:
type: ClusterIP
port: 8090

# Probe configuration (brain handles long-running LLM calls)
livenessProbe:
initialDelaySeconds: 30
periodSeconds: 60
timeoutSeconds: 30
failureThreshold: 5

readinessProbe:
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 6

autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 8
targetCPUUtilizationPercentage: 70

extraEnv: []

GPU Inference (Optional)​

Enable local LLM inference on GPU nodes. In the default scan-only mode, this offloads high-throughput operations to the local GPU while keeping cloud APIs for quality-critical synthesis. Set LOCAL_LLM_FOR_SCANS_ONLY=false to route all operations locally and eliminate cloud API costs entirely.

gpu:
# NVIDIA device plugin DaemonSet
# Detects GPU resources on nodes and exposes them to Kubernetes
devicePlugin:
enabled: false
image:
repository: nvcr.io/nvidia/k8s-device-plugin
tag: "v0.17.0"

# vLLM inference server (OpenAI-compatible API)
llmServer:
enabled: false
replicaCount: 1
image:
# Use vllm/vllm-openai for open-source models, or point at any other
# OpenAI-compatible image - including one you trained or fine-tuned
# yourself and mirrored to your own registry. Align does not distribute
# a pre-built fine-tuned decision model image.
repository: vllm/vllm-openai
tag: "v0.8.5"
port: 8001
resources:
requests:
memory: "14Gi"
cpu: "2000m"
nvidia.com/gpu: "1"
limits:
memory: "16Gi"
cpu: "4000m"
nvidia.com/gpu: "1"
# Pass additional args/env to vLLM (e.g., model name, quantization)
extraEnv:
- name: MODEL_NAME
value: "meta-llama/Llama-3.1-8B-Instruct"

Requirements:

  • GPU nodes with NVIDIA drivers (e.g., AWS al2023-nvidia AMI, or install nvidia-driver + nvidia-container-toolkit)
  • Node label: node-type: gpu
  • Node taint: nvidia.com/gpu=true:NoSchedule

When gpu.llmServer.enabled is true, the Helm chart automatically injects LOCAL_LLM_SERVER_URL into Brain pods. By default, LOCAL_LLM_FOR_SCANS_ONLY=true routes high-throughput operations (Discover scans, bulk analysis) to the local GPU while keeping synthesis on cloud APIs for quality. Set LOCAL_LLM_FOR_SCANS_ONLY=false in Brain's extraEnv to route all operations to the local GPU.

See LLM Configuration for full GPU setup guide.

UI (Frontend)​

ui:
enabled: true
replicaCount: 2

image:
repository: align/ui
tag: "" # empty follows global.imageTag

resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"

service:
type: ClusterIP
port: 3000

ingress:
enabled: true
className: "nginx"
hosts:
- host: app.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: app-tls
hosts:
- app.yourdomain.com

extraEnv: []

Connectors​

connectors:
# Connectors do not follow global.imageTag. The chart ships each one at "latest",
# so set every connector you enable to the same release as the gateway.
# Common settings for all connectors
common:
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"

slack:
enabled: true
replicaCount: 1
image:
repository: align/connector-slack
tag: "v1.2.3"
service:
port: 8081
extraEnv: []

teams:
enabled: true
replicaCount: 1
image:
repository: align/connector-teams
tag: "v1.2.3"
service:
port: 8084
extraEnv: []

jira:
enabled: true
replicaCount: 1
alignCommand: "/align" # Command to trigger capture
image:
repository: align/connector-jira
tag: "v1.2.3"
service:
port: 8083
extraEnv: []

github:
enabled: true
replicaCount: 1
image:
repository: align/connector-github
tag: "v1.2.3"
service:
port: 8085
extraEnv: []

linear:
enabled: false
replicaCount: 1
image:
repository: align/connector-linear
tag: "v1.2.3"
service:
port: 8082
extraEnv: []

# MCP Server for AI Assistants (Claude Desktop, Cursor, VS Code)
align:
enabled: false
replicaCount: 1
image:
repository: align/connector-align
tag: "v1.2.3"
service:
port: 8089
# Expose MCP server externally for remote AI assistants
ingress:
enabled: false
className: "nginx" # or "traefik"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: mcp.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: mcp-tls
hosts:
- mcp.yourdomain.com
extraEnv: []
tip

When the MCP connector is enabled with ingress, AI assistants like Cursor can connect directly to https://mcp.yourdomain.com/tools. For Claude Desktop, you can also use stdio mode (local process) - see AI Assistants Integration.

Secrets​

secrets:
# Create secrets from values (DEV ONLY)
create: false

# LLM API keys
llm:
openaiApiKey: ""
anthropicApiKey: ""
custom:
baseUrl: "" # OpenAI-compatible endpoint
model: "" # Model name
apiKey: "" # Optional API key

# Internal secrets
internal:
encryptionKey: ""
jwtSecret: ""
cookieSecret: ""
serviceAuthToken: ""
note

Each OAuth-backed connector you enable needs credentials, and one without them is reported as not configured. Use your own, from apps you register. A GitHub App uses an app id and private key rather than a client id and secret, and the align connector is auth: none and needs neither. secrets.oauth only renders secrets when secrets.create is true, which is for development; production installs pre-create them. Either way, your gateway talks directly to each provider. See Connector Authentication.

External Secrets​

Align can sync its secrets with the External Secrets Operator. The chart reads externalSecrets.enabled, secretStore.name, secretStore.kind, refreshInterval and the three relay-token flags described in Secrets. It also reads aws.secretPath as the remote key prefix, for every backend, not only AWS. The backend (AWS, GCP, Azure, Vault) is whatever your SecretStore points at. The chart has provider and per-backend keys, but no template reads them.

externalSecrets:
enabled: true
secretStore:
name: my-secret-store # your ClusterSecretStore or SecretStore
kind: ClusterSecretStore
refreshInterval: 1h
aws:
secretPath: "align/production" # remote keys live under <secretPath>/

See Secrets for the properties each key needs.

Telemetry​

telemetry:
enabled: true
samplingRate: "1.0"
batchSize: 100
flushIntervalMs: 5000

# Hourly aggregation CronJob
aggregation:
enabled: true
schedule: "5 * * * *"

# Daily rollup CronJob
dailyRollup:
enabled: true
schedule: "30 1 * * *"

# Data retention (days)
retention:
rawEventDays: 90
hourlyMetricDays: 365
dailyMetricDays: 730

Migrations​

migrations:
enabled: true
image:
repository: align/migrations
tag: "" # empty follows global.imageTag
pullPolicy: Always
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"

Security Context​

securityContext:
# Enable restrictive security context
# Disable for local dev where containers run as root
enabled: true

Node Scheduling​

nodeSelector: {}

tolerations: []

affinity: {}

Service Account​

serviceAccount:
create: true
name: align
annotations: {}

Cloud-specific IAM​

AWS (IRSA)
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/align-role
GCP (Workload Identity)
serviceAccount:
annotations:
iam.gke.io/gcp-service-account: align@PROJECT.iam.gserviceaccount.com
Azure (Workload Identity)
serviceAccount:
annotations:
azure.workload.identity/client-id: "CLIENT_ID"
labels:
azure.workload.identity/use: "true"