Self-Hosted Deployment
Deploy SessionFS on your own Kubernetes cluster.
Overview
Section titled “Overview”SessionFS can be deployed to any Kubernetes cluster using the official Helm chart. The deployment includes:
- API Server – FastAPI application handling session CRUD, sync, and authentication
- MCP Server – Model Context Protocol bridge (optional, experimental: remote MCP for web clients is not yet supported, so you can leave it disabled with
mcp.enabled=false; desktop tools use the localsfs mcp serve) - Web Dashboard – React management interface (optional)
- PostgreSQL – Built-in or external database
- Blob Storage – Local PVC, Amazon S3, or Google Cloud Storage
Prerequisites
Section titled “Prerequisites”- Kubernetes 1.26 or later
- Helm 3.12 or later
kubectlconfigured for your cluster- A PersistentVolume provisioner (most managed clusters include one)
Security Posture
Section titled “Security Posture”SessionFS ships with a hardened default security posture — no action required on your part. The Helm chart and container images meet CIS Kubernetes benchmark and Pod Security Standard restricted profile.
Container images
Section titled “Container images”Both sessionfs-api and sessionfs-mcp Docker images:
- Run as non-root user (UID 10001, dedicated
sessionfssystem user) - Ship with no shell or package manager for the runtime user
- Are scanned on every release via
trivy(CRITICAL/HIGH findings block the pipeline)
Kubernetes SecurityContext
Section titled “Kubernetes SecurityContext”Every pod declared by the chart — API, MCP, dashboard, PostgreSQL, and the helm test hook — runs with:
| Setting | Value |
|---|---|
runAsNonRoot |
true |
runAsUser |
10001 (or 999 for PostgreSQL, matching upstream convention) |
readOnlyRootFilesystem |
true |
allowPrivilegeEscalation |
false |
capabilities.drop |
[ALL] |
seccompProfile.type |
RuntimeDefault |
The PostgreSQL container mounts emptyDir volumes at /tmp and /var/run/postgresql so it can write its socket directory and temp files even with a read-only root filesystem. The persistent data volume (/var/lib/postgresql/data) uses a standard PVC.
Network policies
Section titled “Network policies”The chart does not ship NetworkPolicies by default so the chart works on clusters without a CNI that supports them. To apply restrictive NetworkPolicies:
security: networkPolicies: enabled: trueThe API policy allows egress to PostgreSQL, DNS and public addresses on 443 only. If you poll an internal Git server (see Git Registry Polling), the ranges you list in api.registryPolling.allowedPrivateCidrs are added to it.
Secrets
Section titled “Secrets”All secrets (database credentials, verification secret, encryption key, SMTP credentials, Resend API key) live in Kubernetes Secret objects, never in ConfigMaps. See Secrets Management below.
Verification
Section titled “Verification”You can verify the security posture of a rendered chart with trivy:
helm template sessionfs sessionfs/sessionfs \ --namespace sessionfs \ > /tmp/rendered.yaml
trivy config /tmp/rendered.yaml --severity CRITICAL,HIGHA clean scan should report zero CRITICAL or HIGH misconfigurations.
Installation
Section titled “Installation”1. Add the Helm Repository
Section titled “1. Add the Helm Repository”helm repo add sessionfs https://charts.sessionfs.devhelm repo update2. Create a Namespace
Section titled “2. Create a Namespace”kubectl create namespace sessionfs3. Choose Your Configuration
Section titled “3. Choose Your Configuration”Minimal (development / evaluation)
Section titled “Minimal (development / evaluation)”Single replica, built-in PostgreSQL, no ingress:
helm install sessionfs sessionfs/sessionfs \ --set mcp.enabled=false \ -f values.minimal.yaml \ --namespace sessionfsAccess via port-forward:
kubectl port-forward svc/sessionfs-api 8000:8000 -n sessionfsStandard
Section titled “Standard”Two API replicas, built-in PostgreSQL, ingress enabled:
helm install sessionfs sessionfs/sessionfs \ --set mcp.enabled=false \ --namespace sessionfs \ --set ingress.hosts[0].host=sessionfs.yourdomain.comProduction
Section titled “Production”External database, cloud storage, autoscaling, network policies:
helm install sessionfs sessionfs/sessionfs \ -f values.production.yaml \ --namespace sessionfs \ --set postgresql.enabled=false \ --set externalDatabase.existingSecret=sessionfs-db \ --set security.existingSecret=sessionfs-secrets \ --set storage.type=s3 \ --set storage.s3.bucket=my-sessionfs-bucket \ --set ingress.enabled=true \ --set ingress.className=nginx \ --set ingress.hosts[0].host=sessionfs.yourdomain.com \ --set ingress.hosts[0].paths.api=/api \ --set ingress.hosts[0].paths.dashboard=/ \ --set mcp.enabled=falseSecrets Management
Section titled “Secrets Management”SessionFS requires several secrets for operation. You can either provide them inline in values.yaml (not recommended for production) or reference pre-existing Kubernetes secrets.
Create Secrets Manually
Section titled “Create Secrets Manually”# Application secretskubectl create secret generic sessionfs-secrets \ --namespace sessionfs \ --from-literal=verification-secret="$(openssl rand -hex 32)" \ --from-literal=encryption-key="$(openssl rand -hex 32)" \ --from-literal=resend-api-key="re_your_key_here"
# External database (if not using built-in PostgreSQL)kubectl create secret generic sessionfs-db \ --namespace sessionfs \ --from-literal=database-url="postgresql+asyncpg://user:pass@host:5432/sessionfs"Then reference them in your Helm values:
security: existingSecret: sessionfs-secretsexternalDatabase: existingSecret: sessionfs-dbStorage Configuration
Section titled “Storage Configuration”Local (default)
Section titled “Local (default)”Uses a PersistentVolumeClaim. Suitable for single-node clusters or evaluation.
storage: type: local local: persistence: enabled: true size: 10GiAmazon S3
Section titled “Amazon S3”storage: type: s3 s3: bucket: my-sessionfs-bucket # Bucket name only — no slashes region: us-east-1 prefix: "" # Optional key prefix (e.g. "sessionfs/")Using IRSA (IAM Roles for Service Accounts):
If your EKS nodes use IRSA, no additional credentials are needed. Attach this IAM policy to the service account role:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::my-sessionfs-bucket", "arn:aws:s3:::my-sessionfs-bucket/*" ] } ]}Annotate the service account in your Helm values:
serviceAccount: create: true annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/sessionfs-s3-roleUsing static credentials:
kubectl create secret generic aws-creds \ --namespace sessionfs \ --from-literal=aws-access-key-id=AKIA... \ --from-literal=aws-secret-access-key=...storage: s3: existingSecret: aws-credsGoogle Cloud Storage
Section titled “Google Cloud Storage”storage: type: gcs gcs: bucket: my-sessionfs-bucketIf using Workload Identity, no additional credentials are needed. Otherwise:
kubectl create secret generic gcs-creds \ --namespace sessionfs \ --from-file=gcs-credentials-json=./sa-key.jsonstorage: gcs: existingSecret: gcs-credsDatabase Configuration
Section titled “Database Configuration”Built-in PostgreSQL (default)
Section titled “Built-in PostgreSQL (default)”The chart deploys a single-replica PostgreSQL StatefulSet. Suitable for small deployments.
postgresql: enabled: true auth: username: sessionfs database: sessionfs persistence: size: 10GiExternal Database
Section titled “External Database”For production, use a managed PostgreSQL service (AWS RDS, GCP Cloud SQL, Azure Database for PostgreSQL).
postgresql: enabled: false
externalDatabase: host: your-db-host.region.rds.amazonaws.com port: 5432 username: sessionfs database: sessionfs existingSecret: sessionfs-dbImportant: Do NOT add sslMode or ?sslmode=require to the connection URL. SessionFS handles SSL negotiation automatically via asyncpg. For RDS and Cloud SQL, SSL is negotiated transparently for non-localhost connections.
TLS / HTTPS
Section titled “TLS / HTTPS”Configure TLS through your ingress controller. Example with cert-manager:
ingress: enabled: true className: nginx annotations: cert-manager.io/cluster-issuer: letsencrypt-prod hosts: - host: sessionfs.yourdomain.com paths: api: /api dashboard: / tls: - secretName: sessionfs-tls hosts: - sessionfs.yourdomain.comMonitoring
Section titled “Monitoring”Enable Prometheus ServiceMonitor (requires prometheus-operator):
monitoring: serviceMonitor: enabled: true interval: 30s labels: release: prometheusOperator metrics
Section titled “Operator metrics”The API serves Prometheus metrics at GET /api/v1/admin/metrics: request latency by route, push outcomes and sizes, pulls, handoffs, DLP findings, authentication failures, active users, session count and database pool use, plus business metrics for scheduled sweeps (runs, duration, lag and refused calls), Jira sync, Confluence publishes, webhook deliveries, agent tokens and runs, approvals and review-loop items. Labels never contain names, ids or raw paths unless you turn on per-tenant labels (api.metrics.tenantLabels: true, which adds org_id / project_id ids to some families). The Metrics Reference lists every family, label and label value. The endpoint isn’t anonymous: scrape it with a platform admin key or a service key with the admin_metrics:read scope, supplied as a Bearer token in your scrape config. Set api.metrics.scrapeAnnotations: true to add prometheus.io/* scrape annotations to the API pods. A ready-made Grafana dashboard ships in the repository at docs/observability/sessionfs-ops.json.
Upgrading
Section titled “Upgrading”helm repo updatehelm upgrade sessionfs sessionfs/sessionfs \ --namespace sessionfs \ --reuse-valuesDatabase migrations run automatically as a Helm pre-upgrade hook.
Upgrading to v0.23.0 runs migrations 084–092 (org helper keys, agent run replay columns, the org digest ledger, transfer proposals, resumable uploads, compile schedules, helper audit rows without a project, Jira tables and helper conversations). All are additive except 090, whose downgrade deletes helper audit rows that have no project. Existing projects keep compile scheduling off until someone turns it on. The scheduled compile, org digest and helper retention only run once something calls their sweeps; see Scheduled sweeps.
Upgrading to v0.24.0 runs migration 093 (ticket followers, notifications and per-persona cursors; additive). Also:
- Helm generated secrets now persist across upgrades. Charts before 0.24.0 generated a new
verification-secretandencryption-keyon everyhelm upgrade, which broke stored LLM keys and GitLab/Jira tokens. From 0.24.0 the chart reuses the existing values. If stored keys stopped working after an earlier upgrade, re-enter them once. GitOps flows that render withhelm templatecan’t read the cluster: set both values explicitly or usesecurity.existingSecret. - Set
SFS_VERIFICATION_SECRETto a real secret. Stored LLM keys and integration tokens are encrypted with a key derived from it (existing values still decrypt). With an unset or placeholder value, routes that store or read encrypted secrets return503 encryption_unavailableoutside development. Development meansSFS_ENVIRONMENT=development, orSFS_ENVIRONMENTunset with a SQLite database (the default), so a production install on SQLite must setSFS_ENVIRONMENT=production. - Schedule the ticket digest if you want ticket emails; see Scheduled sweeps.
Upgrading to v0.25.0 runs migrations 094 (helper actions) and 095 (helper settings and the token-usage ledger); both are additive. Also:
- Project editing is limited to the owner and org members. People who only synced a session from a project’s repository can still read it and add knowledge entries, tickets, comments and agent runs, but changing the project returns
403 project_edit_forbidden. See Project Context. - Review collaborator contributions. Tickets and knowledge notes created by people outside your org now wait for an owner or org member to approve or promote them, and work queues with auto-adopt skip open tickets from non-members until they are approved.
Upgrading to v0.26.0 runs migrations 096–100 (project groups, custom Jira templates, group context, Confluence, and two-way Jira sync); all are additive. Also:
- Schedule the Jira sync and Confluence publish sweeps if you use them, so outbound Jira changes are retried and opted-in Confluence pages stay current; see Scheduled sweeps.
- Restricted project visibility is off by default. Turning it on for an org limits members to projects they own, created, or reach through a project group.
Upgrading to v0.27.0 runs migration 101 (direct-to-person ticket assignment; additive). Existing tickets keep their persona assignments. Work-queue completions whose verdict header can’t be parsed are now refused with 422 verdict_header_unparseable; verdict headers may name any reviewer.
Upgrading to v0.28.0 runs migrations 102 (ticket due dates, estimates and the ticket change history) and 103 (Jira sprint and parent fields); both are additive. Also:
- Detect the Jira agile fields once. An existing Jira connection has no sprint or epic-link field until an org admin opens Organization → Integrations → Jira → Connection → Agile fields, runs Detect fields and saves. Until then the publish dialog offers no sprint. See Jira Cloud.
- Check your sweep keys. A
platform_sweeps:runkey only works while the admin who minted it and the user it runs as are still platform admins.sfs admin service-keys listnow shows a key that has stopped working asinactive, with the reason. See Scheduled sweeps. - Optional sweep CronJobs. The chart can now schedule every sweep for you; see Scheduled sweeps.
Upgrading to v0.29.0 runs migrations 104 and 105 (meeting transcripts, participants and action items); both are additive. Also:
- Optional meeting retention sweep. Stored meetings are deleted after the org’s retention window (180 days by default); schedule the sweep if you use meetings. See Scheduled sweeps and Meetings.
Upgrading to v0.29.1 runs migrations 106 (agent run usage, cost, model, role and loop fields), 107 (service-key attribution on wiki revisions), 108 (the reviewed commit on review verdicts) and 109 (service-key attribution on retrieval audit contexts); all are additive. Existing runs carry no usage, and existing verdicts carry no reviewed commit, so merge readiness reports them as not ready until a reviewer posts a verdict bound to the head commit.
Upgrading to v0.29.2 runs migrations 110 (review loop settings on the parent issue and service-key attribution on work queues), 111 (the needs_human work-queue item status) and 112 (the review checklist); all are additive. API keys gain the loops:read and loops:write scopes. No new settings are needed.
Upgrading to v0.30.0 runs migrations 134 (idempotency_keys), 135 (the distilled_from lineage edge; a downgrade turns those edges back into derived_from), 136 (org_concurrency_slots) and 137 (merge_check_posts); all are additive. To clear stored Idempotency-Key responses after 24 hours, call POST /api/v1/admin/sweeps/idempotency-keys hourly with a platform_sweeps:run key, or enable the chart’s opt-in api.idempotencyKeys CronJob. New optional settings: per-org concurrency caps (api.limits.orgConcurrency), the heavy-read timeout (api.limits.heavyReadTimeoutMs, 15 s by default), per-tenant metric labels (api.metrics.tenantLabels) and SLO alert rules (api.metrics.prometheusRule); see Tenancy and Isolation, Metrics Reference and Service Level Objectives. Signup verification links now use SFS_PUBLIC_API_URL, else SFS_DASHBOARD_URL; set one of them if you require email verification. People can now belong to more than one organization; nothing needs to change for existing members.
Upgrading to v0.29.14 runs migrations 131 (artifacts, artifact_edges, artifact_tags), 132 (eval_suites, eval_cases, artifact_family_requirements) and 133 (eval_runs, eval_case_results, agent_runs.eval_run_id and the grader run role); all are additive. API keys gain the evals:read, evals:write, artifacts:read and artifacts:write scopes. To clear eval outputs past their retention (180 days by default), call POST /api/v1/admin/sweeps/eval-outputs daily with a platform_sweeps:run key, or enable the chart’s opt-in api.evalOutputs CronJob. A project holding evaluation or artifact data can’t be merged yet.
Upgrading to v0.29.13 runs migration 130 (the external_contacts and external_contact_aliases tables, meeting_participants.contact_id with the contact resolution, and tickets.assigned_contact_id); it is additive, and a downgrade turns contact-resolved participants back into unresolved ones. API keys gain the contacts:read and contacts:write scopes. To sweep old contacts, set the org’s contact retention and call POST /api/v1/admin/sweeps/contact-retention with a platform_sweeps:run key; it does nothing for orgs that leave retention off.
Upgrading to v0.29.12 runs migration 129 (who recorded each release gate and deployment: actor_type user, service_key or webhook, plus the user or service-key id, on release_gates and deployments); it is additive and existing rows stay empty. Approval requests are now raised only on write paths, so an expired request comes back on the next verdict, decision, policy write, merge-check trigger or PR/MR webhook rather than on a readiness read. No new settings or scopes are needed.
Upgrading to v0.29.11 runs migrations 125 (work_queue_items.prior_implementer_service_key_ids, so a resident rebound to a new key keeps its old key in the item’s implementer identity), 126 (a partial unique index for one recorded run per review round; if an existing database already holds two runs for one round the index is skipped and the rows are printed for review, nothing is deleted), 127 (indexes for merge-check lookups and the delivery-event retention sweep) and 128 (suggested and linked tickets on meeting and chat action items, and the linked item status); all are additive, and a downgrade of 128 turns linked items back into pending ones. The chart’s utility images are now pinned by digest (the tag is kept in a comment); air-gapped installs can point them at a mirror. The chart also gains the opt-in api.attentionHides CronJob. No new settings or scopes are needed.
Upgrading to v0.29.10 needs no migration. Agents (personas), tickets and agent runs are available on every plan; review loops, loop worker runs, workflow templates, releases, the merge check, loop share links and PR/release webhook ingest need a Team or Enterprise plan (the new review_loop feature).
Upgrading to v0.29.9 runs migration 124 (the workflow_templates and workflow_step_children tables and the agent_runs.workflow_step_id column behind Workflow Templates); it is additive, and a downgrade deletes every stored template and the link between workflow loops and their step tickets (the loops and tickets themselves stay). Workflows use the existing loops:read / loops:write scopes. No new settings are needed.
Upgrading to v0.29.8 runs migration 123 (the attention_hides table behind For You’s Hide and Snooze); it is additive, and a downgrade only forgets which items people hid. API keys gain the attention:read scope for service keys that read their minter’s For You feed. The dashboard home moves from the session list to For You; the session list is at /sessions. To delete hides older than 90 days, call POST /api/v1/admin/sweeps/attention-hides daily with a platform_sweeps:run key (the Helm chart’s opt-in api.attentionHides CronJob does this); see Scheduled sweeps. No new settings are needed.
Upgrading to v0.29.7 runs migration 122 (chat threads: the kind, provider and channel_name columns on meeting transcripts, the chat_export source, the chat_extract helper usage source and the conversation_message_meta table); it is additive and every existing row stays a meeting. No new settings or scopes are needed: chat threads use meetings:read / meetings:write, and the meeting retention sweep also removes chat threads. A downgrade deletes every chat thread with its items, participants and reminders.
Upgrading to v0.29.6 runs migrations 120 (approval policies, requests and decisions, plus the approval-pending and deferred-supersede columns on knowledge entries) and 121 (the owner text kept on meeting action items whose owner matched nobody); both are additive. API keys gain the approvals:read scope. No new settings are needed; approval policies need the Enterprise plan.
Upgrading to v0.29.5 runs migration 119 (reminders, and notifications that can carry reminders as well as tickets); it is additive. To deliver due reminders, enable the api.remindersDue CronJob in the Helm chart (it calls the reminders due sweep every 5 minutes with a platform_sweeps:run key).
Upgrading to v0.29.4 runs migrations 117 and 118 (direct release–ticket links, and the link from a rotated service key to the key it replaced); both are additive. No new settings are needed.
Upgrading to v0.29.3 runs migrations 113 to 116 (the review round on agent runs, pull requests, releases, gates and deployments, share links, and per-org delivery webhooks); all are additive. For the GitHub merge check, grant the GitHub App the Checks permission and subscribe it to the Pull request and Release events; without the permission SessionFS comments on the pull request instead. Register each org’s delivery webhooks with sfs org delivery-webhook rotate. No new settings are needed.
Troubleshooting
Section titled “Troubleshooting”Check pod status
Section titled “Check pod status”kubectl get pods -n sessionfskubectl describe pod <pod-name> -n sessionfsView API logs
Section titled “View API logs”kubectl logs -n sessionfs -l app.kubernetes.io/component=api --tail=100Check migration job
Section titled “Check migration job”kubectl get jobs -n sessionfs -l app.kubernetes.io/component=migrationkubectl logs -n sessionfs job/sessionfs-migrate-<revision>Run Helm tests
Section titled “Run Helm tests”helm test sessionfs --namespace sessionfsCommon Issues
Section titled “Common Issues”Pods stuck in Pending: Check that your cluster has a PersistentVolume provisioner and sufficient resources.
Database connection errors: Verify the database URL and credentials. For external databases, ensure network connectivity (security groups, VPC peering).
Ingress not working: Confirm your ingress controller is installed and the ingress class name matches.
Security context errors (runAsNonRoot): SessionFS images currently run as root. Set security contexts in values.yaml:
api: podSecurityContext: runAsNonRoot: falseasyncpg SSL errors: Do not add ?sslmode=require to the database URL — SessionFS handles SSL parameter translation internally. For RDS/Cloud SQL, asyncpg negotiates SSL automatically for non-localhost connections.
API Documentation
Section titled “API Documentation”After deployment, the API is documented via OpenAPI:
- Swagger UI:
https://your-domain/api/docs - ReDoc:
https://your-domain/api/redoc - OpenAPI JSON:
https://your-domain/api/openapi.json
Key endpoints:
| Endpoint | Description |
|---|---|
POST /api/v1/auth/signup |
Create account |
GET /api/v1/auth/me |
Current user profile |
GET /api/v1/sessions |
List sessions |
POST /api/v1/sessions/{id}/audit |
Run LLM Judge |
GET /health |
Health check |
Git Registry Polling
Section titled “Git Registry Polling”Organization marketplace registries on https:// can be polled by the API, which publishes each new matching tag through the normal publish checks (see Skills Marketplace). Polling only runs when the poller endpoint is called, never in user requests.
Schedule it. Mint a service key with only the skills_registry:poll scope (a platform admin must mint it), store it in a Secret, and enable the CronJob:
sfs admin service-keys create --org <ORG_ID> --name registry-poller --scope skills_registry:poll --output-key > key.txtkubectl create secret generic sessionfs-registry-poll --from-file=registry-poll-api-key=key.txt -n sessionfsapi: registryPolling: enabled: true cronJob: enabled: true schedule: "*/5 * * * *" existingSecret: sessionfs-registry-pollThe CronJob only sends one request to the API service; the fetch happens in the API pods. Overlapping runs are safe (each registry is leased while it is polled, and the lease is renewed as it works). Timing: each registry gets a 300 s fetch budget (timeoutSeconds) plus 300 s for publishing; a run stops claiming registries once that would overrun runBudgetSeconds (1200 s), and the CronJob’s curl and job deadlines are 1320 s and 1380 s. If you raise the budget, keep your own cron’s timeout above budget + 120 s. maxPerOrg (10) caps polling-enabled registries per organization and maxPerOrgPerRun (2) keeps one organization from starving the others. Without the CronJob, run sfs admin registries poll from any cron with SESSIONFS_API_KEY set to that key.
Network egress. The API pods need outbound HTTPS to your Git hosts. By default only globally routable addresses are fetched; private ranges, loopback, link-local and cloud metadata addresses are refused, every resolved address is checked, Git is pinned to the checked address (no DNS rebinding) and redirects are not followed. To poll an internal GitLab, opt in explicitly with its range (loopback, link-local and metadata stay refused):
api: registryPolling: allowedPrivateCidrs: - 10.20.0.0/16This sets SFS_REGISTRY_POLL_ALLOWED_PRIVATE_CIDRS and, with security.networkPolicies.enabled, opens API egress to that range on 443. Outbound proxies are not used by the poller (its Git environment is replaced to keep the address pin effective).
Credentials for private repositories. Create one Secret per credential with a token, the org id allowed to use it and the hosts it may be sent to (all three required), and optionally a username (GitLab deploy tokens need theirs), and list it:
kubectl create secret generic acme-gitlab -n sessionfs \ --from-literal=token=<deploy-token> --from-literal=org=<ORG_ID> \ --from-literal=username=gitlab+deploy-token-12 --from-literal=hosts=gitlab.internal.exampleapi: registryPolling: credentialSecrets: - acme-gitlabThe org’s admin then sets credential_secret_ref to k8s:acme-gitlab. Alternatively expose a credential as SFS_REGISTRY_CRED_<NAME> with SFS_REGISTRY_CRED_<NAME>_ORG and SFS_REGISTRY_CRED_<NAME>_HOSTS (via api.env from a Secret) and reference it as env:SFS_REGISTRY_CRED_<NAME>. A credential bound to another organization or host, or missing either binding, is refused; the organization only sees a generic message and the API log records the reason. The token reaches Git only as an HTTP header for that registry’s origin; it is never put in a URL, an argument list, a response or a log.
The API image includes git (2.44 or newer is required). Clones go to a /tmp emptyDir (api.registryPolling.tmpSizeLimit, default 1 GiB) because the root filesystem is read-only. Set api.registryPolling.enabled: false to turn polling off entirely.
Email Configuration
Section titled “Email Configuration”SessionFS supports three email providers: Resend (SaaS), SMTP (enterprise), or none (air-gapped).
Resend (default for cloud)
Section titled “Resend (default for cloud)”helm install sessionfs sessionfs/sessionfs \ --set mcp.enabled=false \ --set email.provider=resend \ --set email.resend.apiKey=$RESEND_KEYSMTP (enterprise / internal)
Section titled “SMTP (enterprise / internal)”helm install sessionfs sessionfs/sessionfs \ --set mcp.enabled=false \ --set email.provider=smtp \ --set email.smtp.host=smtp.company.internal \ --set email.smtp.port=587 \ --set email.smtp.username=sessionfs \ --set email.smtp.password=$SMTP_PASS \ --set email.fromAddress=sessionfs@company.comFor implicit SSL (port 465):
--set email.smtp.port=465 \ --set email.smtp.ssl=true \ --set email.smtp.tls=falseNo email (air-gapped)
Section titled “No email (air-gapped)”helm install sessionfs sessionfs/sessionfs \ --set mcp.enabled=false \ --set email.provider=none \ --set api.env.SFS_REQUIRE_EMAIL_VERIFICATION=falseUsers will be auto-verified on signup. Email notifications (handoff, retention) will be logged but not sent.
SMTP with Kubernetes Secrets
Section titled “SMTP with Kubernetes Secrets”kubectl create secret generic smtp-creds \ --namespace sessionfs \ --from-literal=username=sessionfs \ --from-literal=password=$SMTP_PASS
helm install sessionfs sessionfs/sessionfs \ --set mcp.enabled=false \ --set email.provider=smtp \ --set email.smtp.host=smtp.company.internal \ --set email.smtp.existingSecret=smtp-credsDatabase Migrations
Section titled “Database Migrations”Automatic (Helm)
Section titled “Automatic (Helm)”Migrations run automatically on helm install and helm upgrade. On upgrades the migration job is a pre-upgrade hook that finishes before new API pods start (hook weight -5). On a fresh install the job is created alongside the database and its credentials and retries until the database is ready; API pods report ready only once the schema is migrated, so new code never serves against an old schema.
ArgoCD
Section titled “ArgoCD”If you deploy SessionFS with ArgoCD (through its Helm source, or by rendering the chart with helm template), the chart orders the rollout with ArgoCD sync waves: the database and its credentials come up first, then the migration job runs, then (in local license mode) the license is seeded, and only after those succeed do the API and MCP deployments roll to the new version.
Nothing to configure: the annotations ship in the chart. As a safety net, API pods only report ready (/health/ready) once the database schema has been migrated for their version, so they never take traffic against an old schema, and the API logs a clear error at startup if the schema does not match. /health/ready returns 200 {"status":"ready","schema":"current"} (or "ahead" after an image rollback) and 503 {"status":"not_ready","schema":"behind"} (or "missing", or "unknown" while a new pod cannot reach the database). It is served on the API pod and, through the chart’s ingress, at https://<your-host>/health/ready; /health on the ingress host is the dashboard’s own liveness check.
If you ever run alembic upgrade head by hand, restart the API deployment afterwards (kubectl rollout restart deployment/sessionfs-api) so its database connections pick up the new schema.
Manual
Section titled “Manual”kubectl exec -it deploy/sessionfs-api -- alembic upgrade headTroubleshooting Migrations
Section titled “Troubleshooting Migrations”If the migration job fails, check logs:
kubectl logs job/sessionfs-migrate-<revision> -n sessionfsIf the schema does not match the running code (for example, a migration was skipped), the API logs a clear error at startup naming both revisions and the fix. Check the API logs if authenticated routes start failing with “column … does not exist”.
Auditing Operator Overrides
Section titled “Auditing Operator Overrides”Operators sometimes need to act without the normal consent flows: move a user’s personal project into the organization while the owner is unavailable, merge a duplicate account, or change a tier. Every platform-admin action the API performs writes a row to the admin_actions table with the admin, the time, an optional reason and, for overrides, the state before and after the change. Use the paved paths below so the audit trail answers “who changed this, when, and why” without reconstructing terminal history.
Move a project into an organization (the owner does not need to initiate or accept):
sfs admin override project-transfer proj_abc123 --to-org org_xyz --expect-from personal \ --reason "Owner on leave; care team needs access (ticket OPS-412)"The move, an accepted transfer record, the audit entry and organization audit events commit in one transaction. Merged projects are refused, open work assigned to org personas the destination lacks is refused unless --force, and any pending transfer for the project is cancelled. The owner of an organization project must be a member of that organization: if the current owner is not (for example, they have left), add --new-owner <user-id> with an active member, and ownership changes in the same transaction. --expect-from makes a retried command safe: it refuses if the project is no longer where you expected it.
Merge a duplicate account. For a duplicate signup (for example a typo in the email), preview and then apply the merge instead of reassigning rows by hand:
sfs admin override user-merge <duplicate-id> <kept-id> --reason "Typo in email domain (ticket OPS-77)" # dry runsfs admin override user-merge <duplicate-id> <kept-id> --reason "Typo in email domain (ticket OPS-77)" --applyThis calls POST /api/v1/admin/users/{source_id}/merge-into/{target_id}. The dry run lists the rows that would move; --apply reassigns them, deduplicates org memberships (higher role wins), revokes the duplicate’s API keys, deactivates it and writes one user_merge audit entry, all in one transaction. It refuses when both accounts hold an active plan or the duplicate owns marketplace assets or has billing attached; resolve those first. See sfs admin override user-merge.
Merge duplicate projects. When the same repository ended up as two projects, the owner can run sfs project merge. When the two projects belong to different users, merge them as an operator:
sfs admin override project-merge <duplicate-project-id> <kept-project-id> --reason "Duplicate from an ssh:// remote (OPS-81)" # dry runsfs admin override project-merge <duplicate-project-id> <kept-project-id> --reason "Duplicate from an ssh:// remote (OPS-81)" --applyEverything moves to the kept project and the duplicate becomes a redirect to it, with one project_merge audit entry. Since v0.22.0 the server stores one canonical remote for every URL form of a repository (ssh://, git://, scp remotes with any user, trailing slashes), so new duplicates of this kind no longer appear. Upgrading runs migration 083, which rewrites stored remotes to the canonical form and prints any project it left alone because its canonical remote already belongs to another project: those are the pairs to merge. See sfs admin override project-merge.
Record a change made by hand. When you have to change data directly in the database, record it afterwards:
sfs admin override record --action user_merge --target-type user --target-id <kept-user-id> \ --reason "Duplicate signup with a typo in the email domain; 62 rows reassigned" \ --before-file before.json --after-file after.jsonRecorded entries are stored with source operator_record: the server attests who recorded them and when, not that the described change happened.
Review the log:
sfs admin audit-log --target-type project --target-id proj_abc123sfs admin audit-log --source operator_record --jsonPUT /api/v1/admin/users/{id}/tier, PUT /api/v1/admin/orgs/{id}/tier and POST /api/v1/admin/orgs/{id}/force-transfer-owner also accept an optional reason in their request body and record the before and after values. The audit log does not protect against someone with direct database access; restrict who holds database credentials, and use database-level logging if you need that guarantee.
Scheduled Sweeps
Section titled “Scheduled Sweeps”SessionFS has no scheduler of its own. These features run only when a scheduled job calls their sweep endpoint. Authenticate with a platform admin key or, better for cron, a platform_sweeps:run service key: it works only while the platform admin who minted it and the user it runs as are both still active platform admins.
Since v0.28.0 this is visible up front: minting or rotating such a key returns a minter_tier_coupling warning, sfs admin service-keys list shows a key that has stopped working as inactive with the reason (coupling_ok and coupling_reason in the API), and a request with that key gets a 403 naming the cause and the fix. Making someone a platform admin just long enough to mint a key doesn’t work: the key stops as soon as they’re demoted. Mint sweep keys as a user who stays a platform admin. See API keys.
| Sweep | Endpoint | What it does |
|---|---|---|
| KB compile | POST /api/v1/admin/sweeps/kb-compile |
Compiles every project whose schedule (nightly / weekly) has elapsed or whose waiting-claims threshold is reached. Deterministic compiler only, one transaction per project; body {"limit": N} (default 100, max 1000) |
| Org digest | POST /api/v1/admin/sweeps/org-digest |
Sends the weekly activity digest to owners and admins of Team and Enterprise orgs. Sends once per org per week however often it runs, and always covers the last completed week, so run it daily |
| Helper retention | POST /api/v1/admin/sweeps/helper-retention |
Deletes session helper messages older than the org’s helper_retention_days (default 90), then removes empty threads |
| Ticket digest | POST /api/v1/admin/sweeps/ticket-notifications |
Emails each user one digest of their pending ticket notifications, following their preference (off, your_turn by default, or all). Access is re-checked before anything is sent (v0.24.0) |
| Jira sync | POST /api/v1/admin/sweeps/jira-sync |
Delivers queued outbound Jira changes (status moves, comments, completion evidence) with retry and backoff, and expires old ones. Body {"limit": N, "budget_seconds": S} (defaults 100 and 50 s) (v0.26.0). See Jira Cloud |
| Meeting retention | POST /api/v1/admin/sweeps/meeting-retention |
Deletes stored meeting transcripts older than the org’s meeting_retention_days (default 180) and meetings deleted more than 30 days ago, together with their participants and action items (v0.29.0). See Meetings |
| For You hides | POST /api/v1/admin/sweeps/attention-hides |
Deletes For You hides and snoozes older than 90 days in bounded batches (batch_size, default 1000, max 5000); idempotent, run it daily (v0.29.8). See For You |
| Idempotency keys | POST /api/v1/admin/sweeps/idempotency-keys |
Deletes stored Idempotency-Key responses older than 24 hours in bounded batches (batch_size, default 1000, max 5000); idempotent, run it hourly. See Idempotent requests |
| Confluence publish | POST /api/v1/admin/sweeps/confluence-publish |
Republishes opted-in wiki pages and, where the mapping allows it, the context document; unchanged pages are skipped and pages edited in Confluence are never overwritten (v0.26.0). See Confluence Cloud |
Suggested schedules: KB compile every 15 minutes, org digest, helper retention, meeting retention and For You hides daily, idempotency keys hourly, ticket digest every 10 minutes, Jira sync every minute (its first retry is due one minute after a failed delivery; without the job a failed change never reaches Jira) and Confluence publish every 15 minutes. Every sweep is safe to run repeatedly and returns immediately when there is nothing to do.
Example CronJob for the KB compile sweep, every 15 minutes:
apiVersion: batch/v1kind: CronJobmetadata: name: sessionfs-kb-compile namespace: sessionfsspec: schedule: "*/15 * * * *" jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: sweep image: curlimages/curl@sha256:94e9e444bcba979c2ea12e27ae39bee4cd10bc7041a472c4727a558e213744e6 # 8.12.1 args: ["-s", "-X", "POST", "https://your-api/api/v1/admin/sweeps/kb-compile", "-H", "Authorization: Bearer $(SWEEP_KEY)", "-H", "Content-Type: application/json", "-d", '{"limit": 100}'] env: - name: SWEEP_KEY valueFrom: secretKeyRef: name: sessionfs-sweep-key key: tokenThe chart can create the CronJob for each sweep. They are all off by default and each needs a Secret holding a platform_sweeps:run service key (existingSecret is required when enabled):
api: ticketNotifications: enabled: true cronJob: schedule: "*/10 * * * *" # every 10 minutes existingSecret: sessionfs-sweep-key secretKey: ticket-notifications-api-key orgDigest: enabled: true cronJob: schedule: "0 4 * * *" # daily existingSecret: sessionfs-sweep-key secretKey: org-digest-api-key kbCompile: enabled: true cronJob: schedule: "*/15 * * * *" # every 15 minutes existingSecret: sessionfs-sweep-key secretKey: kb-compile-api-key helperRetention: enabled: true cronJob: schedule: "0 3 * * *" # daily existingSecret: sessionfs-sweep-key secretKey: helper-retention-api-key jiraSync: enabled: true cronJob: schedule: "* * * * *" # every minute existingSecret: sessionfs-sweep-key secretKey: jira-sync-api-key confluencePublish: enabled: true cronJob: schedule: "*/15 * * * *" # every 15 minutes existingSecret: sessionfs-sweep-key secretKey: confluence-publish-api-key meetingRetention: enabled: true cronJob: schedule: "30 3 * * *" # daily existingSecret: sessionfs-sweep-key secretKey: meeting-retention-api-key remindersDue: enabled: true cronJob: schedule: "*/5 * * * *" # every 5 minutes existingSecret: sessionfs-sweep-key secretKey: reminders-due-api-key attentionHides: enabled: true cronJob: schedule: "45 3 * * *" # daily existingSecret: sessionfs-sweep-key secretKey: attention-hides-api-key idempotencyKeys: enabled: true cronJob: schedule: "20 * * * *" # hourly existingSecret: sessionfs-sweep-key secretKey: idempotency-keys-api-keyEach CronJob pod sends one authenticated request to the API sweep endpoint and exits; the work itself runs in the API pods. The key is read from the Secret into an environment variable and never printed, and the pods don’t mount a Kubernetes service account token. When network policies are enabled, the chart adds an ingress rule so each enabled CronJob can reach the API, and an egress policy that lets the CronJob pods reach only DNS and the API, so the key can’t be sent anywhere else.
With email.provider=none the digest sweep sends nothing but still records each org’s run. Digest links use SFS_DASHBOARD_URL, falling back to an explicitly set SFS_APP_URL, and are left out when neither is set.
Rate Limiting
Section titled “Rate Limiting”Rate limiting is per API key, defaulting to 120 requests per minute.
api: rateLimitPerMinute: 120 # Requests per minute per API key # Set to 0 to disable rate limiting (recommended for internal deployments)Or via environment variable:
api: env: SFS_RATE_LIMIT_PER_MINUTE: "0" # Disable for internal deploymentsArchitecture
Section titled “Architecture”SessionFS uses a reverse proxy architecture. All external traffic routes through the dashboard nginx, which proxies /api/ and /mcp/ to internal ClusterIP services:
Internet -> Ingress -> Dashboard Nginx -> API Service (ClusterIP) -> MCP Service (ClusterIP) -> Static FilesThe ingress template routes everything to the dashboard service. Do NOT create separate ingress rules for /api or /mcp — this causes ALB target groups to stay in “unused” state.
Upload size limit
Section titled “Upload size limit”The dashboard nginx has client_max_body_size configured (default 100MB). Override in values.yaml:
dashboard: clientMaxBodySize: "200m"Upload time limit
Section titled “Upload time limit”Pushing a large session waits for the server to DLP-scan and store it. The dashboard nginx waits up to apiProxyTimeout (default 300s) for the API, and the CLI and daemon wait up to SFS_UPLOAD_TIMEOUT seconds (default 300). If very large sessions time out, raise both together and keep any load balancer idle timeout in front of the chart at least as long:
dashboard: apiProxyTimeout: "600s"export SFS_UPLOAD_TIMEOUT=600 # or upload_timeout = 600 under [sync] in ~/.sessionfs/config.tomlThe chart’s Ingress passes matching timeout and body-size annotations to ingress-nginx by default; anything you set under ingress.annotations takes precedence. Other ingress controllers need their own timeout set.
Sessions over 8 MiB are uploaded in resumable parts (8 MiB by default), each retried on its own; an interrupted push resends only the missing parts, and unfinished uploads expire after 24 hours. Older servers get the single-request push automatically.
Session helper streaming
Section titled “Session helper streaming”The session helper streams answers as Server-Sent Events from POST /api/v1/helper/turns. The chart’s dashboard nginx passes that route through unbuffered with a 600-second timeout. If you put another proxy or load balancer in front of the chart, turn off response buffering for that path and allow long-lived responses (the API sends a keep-alive comment every 15 seconds).
GitLab Integration
Section titled “GitLab Integration”SessionFS supports GitLab merge request comments (cloud and self-hosted instances).
- Create a GitLab personal or project access token with
apiscope - Add a webhook to your GitLab project:
- URL:
https://your-sessionfs-domain/webhooks/gitlab - Secret token: the per-user secret you save in step 3, or a server-wide secret set in your Helm values (
SFS_GITLAB_WEBHOOK_SECRET) - Events: “Merge request events”
- URL:
- Save your instance URL, access token and webhook secret in the dashboard under Settings → Integrations → GitLab (or with
PUT /api/v1/settings/gitlab). The token and secret are write-only: neither the dashboard norGET /api/v1/settings/gitlabshows them again. To accept one server-wide secret instead, set it via Helm:
api: env: SFS_GITLAB_WEBHOOK_SECRET: "your-webhook-secret"Self-hosted installs usually point at an internal GitLab, so the chart allows private, loopback and plain-http instance URLs by default (SFS_GITLAB_ALLOW_PRIVATE_HOSTS=true). Link-local and cloud-metadata addresses stay blocked either way, and the host is re-checked before every request. To restrict the integration to public https GitLab hosts, set:
api: gitlab: allowPrivateHosts: falseThe same applies to custom LLM endpoints (the base_url users can set for the LLM judge and narrative summaries): the chart allows private, loopback and plain-http endpoints by default (SFS_LLM_ALLOW_PRIVATE_HOSTS=true) so an in-cluster Ollama, vLLM or LiteLLM works, and link-local and cloud-metadata addresses stay blocked. To allow only public https endpoints:
api: llm: allowPrivateHosts: falseGitLab MR comments include the same AI session context as GitHub PR comments, including audit findings when available.
Environment Variables
Section titled “Environment Variables”See Environment Variables Reference for the complete list of SFS_* configuration options.
Further Troubleshooting
Section titled “Further Troubleshooting”See Troubleshooting Guide for common errors, sync debugging, and Kubernetes-specific issues.