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.
Option 1: PgBouncer Sidecar (Recommended for Self-Hosted)
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_URLto the proxy endpoint instead of RDS directly
Setup steps:
-
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
-
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" -
Helm values - PgBouncer is disabled by default, so no changes needed:
gateway:pgbouncer:enabled: false # Default, no sidecar needed with RDS ProxyextraEnv:# With RDS Proxy, you can use higher concurrency- name: SQS_IMPORT_WORKER_CONCURRENCYvalue: "30"# App pool is just for local buffering- name: PG_POOL_MAXvalue: "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_MAXto that value per service - Set
SQS_IMPORT_WORKER_CONCURRENCYtoPG_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_contentcolumn is always written empty and a databaseCHECKconstraint (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_jsonholds 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 databaseCHECKconstraint 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 value | Environment variable | Meaning |
|---|---|---|
gateway.shareApprovalMode | SHARE_APPROVAL_MODE | available, required or off. Default available. Any other value fails the render. |
gateway.frontendUrl | FRONTEND_URL | The 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:
- The web app URL. The gateway returns it as
app_urlfromGET /share-requests/config(an origin only, omitted whenFRONTEND_URLis 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. - The browser origin check. Approve and decline refuse any request whose
Originis not this URL's origin. WithFRONTEND_URLunset they refuse everything. - The passkey relying party, when passkey approval ships. The relying party ID will be the
hostname of this URL, so it must be
https, orhttponlocalhostonly. 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.
| Variable | Default | Description |
|---|---|---|
SCHEMA_CHECK_MODE | enforce | enforce = 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"
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:
| Platform | Batch Size | Min Confidence | Notes |
|---|---|---|---|
| Slack | 5 | 0.20 | Smaller batches |
| Teams | 8 | 0.20 | Chat-based items |
| GitHub | 8 | 0.33 | Medium batches - PRs/issues with comments |
| Jira | 8 | 0.33 | Medium batches - structured ticket content |
| Confluence | 8 | 0.33 | Medium 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) andPG_POOL_MAX=15 - Multi-pod with Redis: Use
SQS_IMPORT_WORKER_CONCURRENCY=20-30andPG_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_CONCURRENCYand 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
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:
| Variable | Default | Drops | Set on |
|---|---|---|---|
INGEST_DROP_BOT_MESSAGES | on | Posts Slack attributes to a bot or app (bot_id, subtype: bot_message, app_id) | gateway and slack connector |
INGEST_DROP_MACHINE_NOTIFICATIONS | on | Templated build/test/deploy output, whatever account posted it | gateway 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
-
Create an OAuth application in your identity provider:
- Set the redirect URI to
https://api.yourdomain.com/auth/callback - Enable the
openid,email, andprofilescopes - Note your Client ID and Client Secret
- Set the redirect URI to
-
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" -
Enable OIDC in your values:
gateway:oidc:enabled: truescopes: "openid email profile"defaultTenantId: "your-align-tenant-uuid" # For single-tenant deployments -
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
- Go to Azure Portal → Azure Active Directory → App registrations
- Create a new registration
- Add a redirect URI:
https://api.yourdomain.com/auth/callback - Generate a client secret
- Grant
openid,email, andprofilepermissions
Okta
Issuer URL: https://{your-domain}.okta.com
- Create a new OIDC Web Application in Okta Admin
- Set the redirect URI:
https://api.yourdomain.com/auth/callback - Note the Client ID and Client Secret
Keycloak
Issuer URL: https://{host}/realms/{realm}
- Create a new client in your realm
- Set Access Type to "confidential"
- Add the redirect URI:
https://api.yourdomain.com/auth/callback - 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.
| Variable | Applies to | Purpose |
|---|---|---|
SSO_GROUP_ROLE_MAPPING | OIDC + SAML | JSON object mapping a directory group name to an Align role |
SSO_GROUPS_CLAIM | OIDC | Token claim that lists the user's groups (default groups) |
SAML_GROUPS_ATTRIBUTE | SAML | Assertion attribute that lists the user's groups (default groups) |
SAML_DEFAULT_TENANT_ID | SAML | Tenant 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_adminof 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.
- Signed in as an org admin (an interactive login, not an API key), create a token:
POST /admin/scim-tokenswith{"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). - In your identity provider, set the SCIM base URL to
https://<your-api-host>/scim/v2and the bearer token to the value from step 1. - Assign users and groups. Align supports
UsersandGroups(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
alignRoleattribute 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 activeorg_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-nvidiaAMI, or installnvidia-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: []
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: ""
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"