1"use strict";(globalThis.webpackChunkminutes_docs=globalThis.webpackChunkminutes_docs||[]).push([[625],{4246(e,n,s){s.r(n),s.d(n,{assets:()=>c,contentTitle:()=>o,default:()=>a,frontMatter:()=>d,metadata:()=>r,toc:()=>l});const r=JSON.parse('{"id":"admin/configuration","title":"Configuration reference","description":"Everything about a minutes instance is configured through environment variables in a single .env file that lives next to your docker-compose.yml (in deploy/single-box). You create it once during Deploy by copying the template:","source":"@site/docs/admin/configuration.md","sourceDirName":"admin","slug":"/admin/configuration","permalink":"/docs/admin/configuration","draft":false,"unlisted":false,"editUrl":"https://github.com/arjmandi/minutes/tree/main/docs/admin/configuration.md","tags":[],"version":"current","sidebarPosition":3,"frontMatter":{"sidebar_position":3,"title":"Configuration reference"},"sidebar":"docs","previous":{"title":"Creating & managing users","permalink":"/docs/admin/users"},"next":{"title":"Getting started","permalink":"/docs/getting-started"}}');var t=s(4848),i=s(8453);const d={sidebar_position:3,title:"Configuration reference"},o="Configuration reference",c={},l=[{value:"Required variables",id:"required-variables",level:2},{value:"Identity",id:"identity",level:2},{value:"STT and translation keys",id:"stt-and-translation-keys",level:2},{value:"The bring-your-own-key model",id:"the-bring-your-own-key-model",level:3},{value:"Data residency (Soniox region)",id:"data-residency-soniox-region",level:3},{value:"Capture settings live in the extension, not <code>.env</code>",id:"capture-settings-live-in-the-extension-not-env",level:3},{value:"Translation targets",id:"translation-targets",level:3},{value:"Durable capture",id:"durable-capture",level:2},{value:"Behavior and limits",id:"behavior-and-limits",level:2},{value:"Domain vs. IP, and TLS",id:"domain-vs-ip-and-tls",level:2},{value:"Backups",id:"backups",level:2},{value:"Updating",id:"updating",level:2},{value:"Tuning concurrency",id:"tuning-concurrency",level:2}];function h(e){const n={a:"a",admonition:"admonition",code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",header:"header",li:"li",p:"p",pre:"pre",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,i.R)(),...e.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(n.header,{children:(0,t.jsx)(n.h1,{id:"configuration-reference",children:"Configuration reference"})}),"\n",(0,t.jsxs)(n.p,{children:["Everything about a minutes instance is configured through environment variables in a single ",(0,t.jsx)(n.code,{children:".env"})," file that lives next to your ",(0,t.jsx)(n.code,{children:"docker-compose.yml"})," (in ",(0,t.jsx)(n.code,{children:"deploy/single-box"}),"). You create it once during ",(0,t.jsx)(n.a,{href:"/admin/deploy",children:"Deploy"})," by copying the template:"]}),"\n",(0,t.jsx)(n.pre,{children:(0,t.jsx)(n.code,{className:"language-bash",children:"cp .env.example .env\n"})}),"\n",(0,t.jsxs)(n.p,{children:["This page is the reference for that file: every variable the operator sets, its exact name and default, and how the bring-your-own-key model works. The defaults shown here come straight from the application's settings (",(0,t.jsx)(n.code,{children:"app/config.py"}),") and the deployment template (",(0,t.jsx)(n.code,{children:"deploy/single-box/.env.example"}),")."]}),"\n",(0,t.jsx)(n.admonition,{title:"Variable naming",type:"note",children:(0,t.jsxs)(n.p,{children:["Application settings are read with the prefix ",(0,t.jsx)(n.code,{children:"MINUTES_"})," (so the ",(0,t.jsx)(n.code,{children:"auth_secret"})," setting is the ",(0,t.jsx)(n.code,{children:"MINUTES_AUTH_SECRET"})," env var). A few infrastructure variables \u2014 ",(0,t.jsx)(n.code,{children:"DOMAIN"}),", ",(0,t.jsx)(n.code,{children:"POSTGRES_PASSWORD"}),", ",(0,t.jsx)(n.code,{children:"MINIO_ROOT_USER"}),", ",(0,t.jsx)(n.code,{children:"MINIO_ROOT_PASSWORD"}),", ",(0,t.jsx)(n.code,{children:"LANDING_DIR"})," \u2014 are consumed by Docker Compose / the bootstrap scripts, not the app, so they have ",(0,t.jsx)(n.strong,{children:"no"})," prefix. The app also ignores any unknown variable, so an extra key in ",(0,t.jsx)(n.code,{children:".env"})," is harmless."]})}
1),"\n",(0,t.jsx)(n.admonition,{title:"Apply changes",type:"tip",children:(0,t.jsxs)(n.p,{children:["After editing ",(0,t.jsx)(n.code,{children:".env"}),", re-run ",(0,t.jsx)(n.code,{children:"docker compose up -d"})," from ",(0,t.jsx)(n.code,{children:"deploy/single-box"}),". Compose recreates only the containers whose environment changed. There's no separate reload step."]})}),"\n",(0,t.jsx)(n.h2,{id:"required-variables",children:"Required variables"}),"\n",(0,t.jsxs)(n.p,{children:["These must be set before the stack will start. In the deployment, the backend runs with ",(0,t.jsx)(n.code,{children:"MINUTES_APP_ENV=prod"}),", which ",(0,t.jsx)(n.strong,{children:"fails closed"}),": it refuses to boot on a weak or default secret (see the note under ",(0,t.jsx)(n.code,{children:"MINUTES_AUTH_SECRET"}),")."]}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Purpose"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"DOMAIN"})}),(0,t.jsxs)(n.td,{children:["Your public hostname, e.g. ",(0,t.jsx)(n.code,{children:"meet.example.com"}),". A DNS ",(0,t.jsx)(n.code,{children:"A"})," record must point at the VPS. Caddy uses it to fetch a Let's Encrypt certificate and to serve ",(0,t.jsx)(n.code,{children:"https://"})," + ",(0,t.jsx)(n.code,{children:"wss://"}),"."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_AUTH_SECRET"})}),(0,t.jsxs)(n.td,{children:["Signing secret for the capability tokens the capture pipeline uses. Must be a strong, non-default value of ",(0,t.jsx)(n.strong,{children:"at least 32 bytes"}),"."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_SECRET_KEY"})}),(0,t.jsxs)(n.td,{children:["AES-256-GCM key that encrypts each user's stored provider API keys at rest. Must also be ",(0,t.jsx)(n.strong,{children:"at least 32 bytes"})," and non-default."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"POSTGRES_PASSWORD"})}),(0,t.jsx)(n.td,{children:"Password for the bundled Postgres database. Any strong value."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINIO_ROOT_PASSWORD"})}),(0,t.jsxs)(n.td,{children:["Admin password for the bundled MinIO object store (",(0,t.jsx)(n.strong,{children:"\u2265 8 chars"}),"). Used only by the one-shot bootstrap that creates the bucket and the app's scoped service account \u2014 never by the app itself."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_S3_SECRET_KEY"})}),(0,t.jsxs)(n.td,{children:["Secret for the ",(0,t.jsx)(n.strong,{children:"app-scoped"})," MinIO service account the backend uses for audio storage (",(0,t.jsx)(n.strong,{children:"\u2265 8 chars"}),"). Least-privilege: limited to the ",(0,t.jsx)(n.code,{children:"minutes-audio"})," bucket, not the root credential."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_PUBLIC_ORIGIN"})}),(0,t.jsxs)(n.td,{children:["The origin browsers use to reach the app, ",(0,t.jsx)(n.code,{children:"https://"})," + your domain, no path. It forms the sign-in redirect URI and decides that cookies are ",(0,t.jsx)(n.code,{children:"Secure"}),"; it must be ",(0,t.jsx)(n.code,{children:"https"})," outside development."]})]})]})]}),"\n",(0,t.jsx)(n.h2,{id:"identity",children:"Identity"}),"\n",(0,t.jsxs)(n.p,{children:[(0,t.jsx)(n.code,{children:"MINUTES_IDENTITY"})," selects how people sign in. Both editions share the same server-side sessions (an opaque, hashed cookie for the browser; device tokens for the extension and the iOS app)."]}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Default"}),(0,t.jsx)(n.th,{children:"Purpose"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_IDENTITY"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"local"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"local"}),": Community edition, password accounts created with ",(0,t.jsx)(n.code,{children:"app.admin"}),". ",(0,t.jsx)(n.code,{children:"oddproof"}),": people sign in with their Oddproof account through OpenID Connect (authorization code flow with PKCE); accounts are created at first sign-in and the server holds no passwords."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_OIDC_ISSUER"})}),(0,t.jsx)(n.td,{children:"\u2014"}),(0,t.jsxs)(n.td,{children:["Required with ",(0,t.jsx)(n.code,{children:"oddproof"}),": the issuer, ",(0,t.jsx)(n.code,{children:"https://identity.oddproof.ai/realms/oddproof"}),"."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_OIDC_CLIENT_ID"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"minutes"})}),(0,t.jsxs)(n.td,{children:["The realm's public client. Its registered redirect URI must be ",(0,t.jsx)(n.code,{children:"MINUTES_PUBLIC_ORIGIN"})," + ",(0,t.jsx)(n.code,{children:"/auth/callback"}),"."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_WEB_SESSION_TTL_S"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"2592000"})}),(0,t.jsx)(n.td,{children:"Browser session lifetime (30 days)."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_DEVICE_TOKEN_TTL_S"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"2592000"})}),(0,t.jsx)(n.td,{children:"Extension and iOS device-token lifetime (30 days)."})]})]})]}),"\n",(0,t.jsx)(n.p,{children:"Generate the two secrets with:"}),"\n",(0,t.jsx)(n.pre,{children:(0,t.jsx)(n.code,{className:"language-bash",children:"openssl rand -hex 32 # one for MINUTES_AUTH_SECRET, one for MINUTES_SECRET_KEY\n"})}),"\n",(0,t.jsx)(n.admonition,{title:"The prod secret guard is not optional",type:"danger",children:(0,t.jsxs)(n.p,{children:["On any non-development environment, the backend validates ",(0,t.jsx)(n.code,{children:"MINUTES_AUTH_SECRET"})," and ",(0,t.jsx)(n.code,{children:"MINUTES_SECRET_KEY"})," at startup and ",(0,t.jsx)(n.strong,{children:"raises an error"}),' if either is the built-in default or shorter than 32 bytes. This is deliberate: it prevents shipping a publicly known signing key. If the backend container won\'t start and the logs mention a "strong (>=32 byte) non-default secret", this is why.']})}),"\n",(0,t.jsxs)(n.p,{children:["The MinIO access-key ",(0,t.jsx)(n.em,{children:"names"})," have safe defaults in the shipped ",(0,t.jsx)(n.code,{children:".env.example"})," and rarely need changing:"]}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Default"}),(0,t.jsx)(n.th,{children:"Notes"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINIO_ROOT_USER"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"minutes-admin"})}),(0,t.jsx)(n.td,{children:"MinIO admin username (bootstrap only)."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_S3_ACCESS_KEY"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"minutes-app"})}),(0,t.jsxs)(n.td,{children:["App-scoped MinIO service-account name (",(0,t.jsx)(n.strong,{children:"\u2265 3 chars"}),"). The backend uses this, not root. (The bare application default in ",(0,t.jsx)(n.code,{children:"app/config.py"})," is ",(0,t.jsx)(n.code,{children:"minutes"}),"; the single-box template ships ",(0,t.jsx)(n.code,{children:"minutes-app"}),".)"]})]})]})]}),"\n",(0,t.jsx)(n.h2,{id:"stt-and-translation-keys",children:"STT and translation keys"}),"\n",(0,t.jsxs)(n.p,{children:["minutes talks to exactly two external services, and ",(0,t.jsx)(n.strong,{children:"only"})," these two ever receive your data: ",(0,t.jsx)(n.strong,{children:"Soniox"})," for speech-to-text, and ",(0,t.jsx)(n.strong,{children:"Anthropic (Claude)"})," for translation. Everything else stays on your box."]}),"\n",(0,t.jsxs)(n.p,{children:["Both keys are ",(0,t.jsx)(n.strong,{children:"per-user"}),": each user pastes their own under ",(0,t.jsx)(n.strong,{children:"Settings \u2192 API keys"})," in the web app, stored encrypted with ",(0,t.jsx)(n.code,{children:"MINUTES_SECRET_KEY"})," (AES-256-GCM). The server-wide variables below are ",(0,t.jsx)(n.strong,{children:"optional fallbacks"}),"."]}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Default"}),(0,t.jsx)(n.th,{children:"Scope"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_API_KEY"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.em,{children:"(empty)"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.strong,{children:"Server-wide fallback."})," Used for live capture ",(0,t.jsx)(n.strong,{children:"only when the capturing user has not set the
1ir own Soniox key"}),". Leave empty to require per-user keys."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_REGION"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"us"})}),(0,t.jsxs)(n.td,{children:["Data-residency region (",(0,t.jsx)(n.code,{children:"us"})," | ",(0,t.jsx)(n.code,{children:"eu"}),") for that ",(0,t.jsx)(n.strong,{children:"fallback"})," key. Per-user keys carry their own region (see below)."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_ANTHROPIC_API_KEY"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.em,{children:"(empty)"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.strong,{children:"Server-wide, optional."})," Usually left empty \u2014 translation uses each user's own key."]})]})]})]}),"\n",(0,t.jsx)(n.h3,{id:"the-bring-your-own-key-model",children:"The bring-your-own-key model"}),"\n",(0,t.jsx)(n.p,{children:"This is the most important thing to understand about minutes' key handling. There are three workflows, and they draw on different keys:"}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Workflow"}),(0,t.jsx)(n.th,{children:"Speech-to-text uses\u2026"}),(0,t.jsx)(n.th,{children:"Translation uses\u2026"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.strong,{children:"Live capture"})," (extension streams a tab)"]}),(0,t.jsxs)(n.td,{children:["the ",(0,t.jsx)(n.strong,{children:"owner's own"})," Soniox key (falls back to ",(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_API_KEY"}),")"]}),(0,t.jsxs)(n.td,{children:["the ",(0,t.jsx)(n.strong,{children:"owner's own"})," Anthropic key"]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.strong,{children:"Audio upload"})," (user uploads a recording)"]}),(0,t.jsxs)(n.td,{children:["the ",(0,t.jsx)(n.strong,{children:"uploader's own"})," Soniox key"]}),(0,t.jsxs)(n.td,{children:["the ",(0,t.jsx)(n.strong,{children:"uploader's own"})," Anthropic key"]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.strong,{children:'On-demand "translate this line"'})}),(0,t.jsx)(n.td,{children:"\u2014"}),(0,t.jsxs)(n.td,{children:["the ",(0,t.jsx)(n.strong,{children:"user's own"})," Anthropic key"]})]})]})]}),"\n",(0,t.jsx)(n.p,{children:"In short:"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Soniox (speech-to-text) is bring-your-own."})," Each user sets their own Soniox key in Settings; it transcribes both their ",(0,t.jsx)(n.strong,{children:"live captures and their uploads"}),". A server-wide ",(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_API_KEY"}),", if set, is used ",(0,t.jsx)(n.strong,{children:"only as a fallback"})," for live capture when a user hasn't added their own \u2014 convenient for a quick demo or a single-tenant box. Leave it empty to require every user to bring a key."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Translation is always bring-your-own."})," Live, upload, and on-demand translation all use each ",(0,t.jsx)(n.strong,{children:"user's own"})," Anthropic key from Settings. If a user has no Anthropic key, translation is simply off for them \u2014 STT is unaffected."]}),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["Get a Soniox key at ",(0,t.jsx)(n.a,{href:"https://soniox.com",children:"soniox.com"})," and an Anthropic key at ",(0,t.jsx)(n.a,{href:"https://console.anthropic.com",children:"console.anthropic.com"}),". For more on the privacy boundary, see ",(0,t.jsx)(n.a,{href:"/reference/limitations",children:"Limitations"}),"."]}),"\n",(0,t.jsx)(n.admonition,{title:"Dual-source capture opens two Soniox connections",type:"note",children:(0,t.jsxs)(n.p,{children:["A live capture can include two independent sources kept fully separate: the ",(0,t.jsx)(n.strong,{children:"Online stream"})," (the browser tab's audio) and the ",(0,t.jsx)(n.strong,{children:"Host mic"})," (the capturing user's own microphone, ",(0,t.jsx)(n.strong,{children:"off by default"}),", toggled in the extension). When both are on, the capture opens ",(0,t.jsx)(n.strong,{children:"two concurrent realtime Soniox connections on the capturing user's own key"})," (or the server fallback). If that Soniox plan caps concurrency, the second connection is rejected and only that source shows an error \u2014 the other keeps recording. This does not change the key model above; it just means a user who runs both sources needs Soniox concurrency for two streams. See ",(0,t.jsx)(n.a,{href:"/reference/limitations",children:"Limitations"})," and ",(0,t.jsx)(n.a,{href:"/reference/troubleshooting",children:"Troubleshooting"}),"."]})}),"\n",(0,t.jsx)(n.h3,{id:"data-residency-soniox-region",children:"Data residency (Soniox region)"}),"\n",(0,t.jsxs)(n.p,{children:["Soniox offers ",(0,t.jsx)(n.strong,{children:"EU data-residency endpoints"}),". Each user picks the region of ",(0,t.jsx)(n.strong,{children:"their"})," key under ",(0,t.jsx)(n.strong,{children:"Settings \u2192 API keys \u2192 Soniox region"}),", and that choice routes both their live capture and their uploads:"]}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Region"}),(0,t.jsx)(n.th,{children:"Endpoints"}),(0,t.jsx)(n.th,{children:"Where audio is processed"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"us"})," (default)"]}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"api.sonio
1x.com"})," / ",(0,t.jsx)(n.code,{children:"stt-rt.soniox.com"})]}),(0,t.jsx)(n.td,{children:"United States"})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"eu"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"api.eu.soniox.com"})," / ",(0,t.jsx)(n.code,{children:"stt-rt.eu.soniox.com"})]}),(0,t.jsx)(n.td,{children:"European Union (end to end)"})]})]})]}),"\n",(0,t.jsxs)(n.p,{children:["The region must match the Soniox ",(0,t.jsx)(n.strong,{children:"project"})," the key was created in \u2014 an EU-project key only works on the EU endpoints, and vice-versa. ",(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_REGION"})," sets the region for the ",(0,t.jsx)(n.strong,{children:"server-wide fallback key only"}),"; per-user keys store their own. (Anthropic is not region-selectable here.)"]}),"\n",(0,t.jsx)(n.admonition,{title:"For a fully EU-resident instance",type:"tip",children:(0,t.jsxs)(n.p,{children:["Have each user create their key in an ",(0,t.jsx)(n.strong,{children:"EU"})," Soniox project and select ",(0,t.jsx)(n.strong,{children:"EU"})," in Settings. If you set a server fallback key, make it an EU key and set ",(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_REGION=eu"})," too, so even users without their own key stay in-region."]})}),"\n",(0,t.jsxs)(n.h3,{id:"capture-settings-live-in-the-extension-not-env",children:["Capture settings live in the extension, not ",(0,t.jsx)(n.code,{children:".env"})]}),"\n",(0,t.jsxs)(n.p,{children:["A few capture behaviors are ",(0,t.jsx)(n.strong,{children:"per-user, set in the Chrome extension's options page"})," \u2014 they are ",(0,t.jsx)(n.em,{children:"not"})," environment variables and there is nothing to configure here for them:"]}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Host mic"})," \u2014 off by default; toggled per capture in the extension popup. The options page holds the ",(0,t.jsx)(n.strong,{children:"mic device"}),", a ",(0,t.jsx)(n.strong,{children:"live level test"}),", and an ",(0,t.jsx)(n.strong,{children:"echo-cancellation"})," toggle (",(0,t.jsx)(n.strong,{children:"default off"}),": with the tab playing through speakers, AEC can clip the host's voice; leave it off on headphones too)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Silence-suspend"})," \u2014 a cost-saver in the extension settings, ",(0,t.jsx)(n.strong,{children:"default on"}),". During sustained silence the Host mic stops streaming to Soniox to cut billed seconds, and resumes on speech. Segment timestamps still track real meeting time \u2014 dropped silence is added back server-side \u2014 so the transcript timeline stays correct. If quiet speech is being dropped, a user can turn silence-suspend off in the extension settings (see ",(0,t.jsx)(n.a,{href:"/reference/troubleshooting",children:"Troubleshooting"}),")."]}),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["These affect what the extension sends to Soniox on the ",(0,t.jsx)(n.strong,{children:"user's own"})," key; they have no server-side variable."]}),"\n",(0,t.jsx)(n.h3,{id:"translation-targets",children:"Translation targets"}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Default"}),(0,t.jsx)(n.th,{children:"Notes"})]})}),(0,t.jsx)(n.tbody,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_TRANSLATION_TARGETS"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:'["en"]'})," (template sets ",(0,t.jsx)(n.code,{children:'["de"]'}),")"]}),(0,t.jsx)(n.td,{children:"JSON array of default target languages for live capture."})]})})]}),"\n",(0,t.jsxs)(n.p,{children:["This is the default output language(s) for live meetings. It's a JSON array, e.g. ",(0,t.jsx)(n.code,{children:'["de"]'})," or ",(0,t.jsx)(n.code,{children:'["en","fa"]'}),". The application's built-in default is ",(0,t.jsx)(n.code,{children:'["en"]'}),", but the shipped ",(0,t.jsx)(n.code,{children:".env.example"})," (and ",(0,t.jsx)(n.code,{children:"docker-compose.yml"}),") sets it to ",(0,t.jsx)(n.code,{children:'["de"]'}),". A target equal to the detected source language is ",(0,t.jsx)(n.strong,{children:"skipped"})," \u2014 so the bare app default of ",(0,t.jsx)(n.code,{children:'["en"]'})," produces no output for English speech. Pick targets that differ from what's being spoken. Supported languages are English (",(0,t.jsx)(n.code,{children:"en"}),"), German (",(0,t.jsx)(n.code,{children:"de"}),"), and Persian (",(0,t.jsx)(n.code,{children:"fa"}),", right-to-left). Translation only actually runs when the user has an Anthropic key."]}),"\n",(0,t.jsx)(n.h2,{id:"durable-capture",children:"Durable capture"}),"\n",(0,t.jsxs)(n.p,{children:["Recordings are captured as checksummed audio ranges on one sample clock, sealed with a\nmanifest, and transcribed by durable work that the API process runs itself (the scheduler's\n",(0,t.jsx)(n.code,{children:"python -m app.v2.worker --once"})," is the backstop). What a sealed recording may become is\nconfiguration:"]}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Default"}),(0,t.jsx)(n.th,{children:"Meaning"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_V2_BATCH_ENABLED"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"false"})}),(0,t.jsx)(n.td,{children:"Transcribe sealed recordings with Soniox's file API (the service key in the hosted edition, each person's own key in Community)."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_V2_LIVE_ENABLED"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"false"})}),(0,t.jsx)(n.td,{children:"Live transcription of captures as they arrive (Soniox real-time). Enable after its qualification."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_V2_WEB_CAPTURE_ENABLED"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"false"})}),(0,t.jsx)(n.td,{children:"Let the web app and the extension record. Requires batch or live transcription."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"MINUTES_SONIOX_REGION"})," (hosted)"]}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"us"})}),(0,t.jsx)(n.td,{children:"The region of the service key. The hosted service is meant to run on an EU project; a non-EU region is logged as a warning at startup, not refused."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_V2_LIVE_SILENCE_CLOSE_SECONDS"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"2.0"})}),(0,t.jsx)(n.td,{children:"How long a silence may last before the live connection to the provider is closed and reopened at the next speech."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_AUDIO_ROOT"})}),(0,t.jsx)(n.td,{children:"empty"}),(0,t.jsx)(n.td,{children:"Without S3, the directory for audio objects (written and fsynced before any receipt). Empty keeps an in-memory store that is only acceptable for tests."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_LEGACY_API_ENABLED"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"true"})}),(0,t.jsxs)(n.td,{children:["The pre-v2 capture pipeline (",(0,t.jsx)(n.code,{children:"/ingest"}),", ",(0,t.jsx)(n.code,{children:"/api/meetings"}),", the old extension, the app at ",(0,t.jsx)(n.code,{children:"/legacy"}),"). Switch off after ",(0,t.jsx)(n.code,{children:"python -m app.v2.legacy_import"})," has brought the old recordings over; the old routes then answer ",(0,t.jsx)(n.code,{children:"410 use_v2_api"}),"."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_WORKER_IN_PROCESS"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"true"})}),(0,t.jsx)(n.td,{children:"Run durable work inside the API process. Leave on; tests switch it off."})]})]})]}),"\n",(0,t.jsxs)(n.p,{children:["Audio past ",(0,t.jsx)(n.code,{children:"MINUTES_RETENTION_DAYS"})," is deleted by ",(0,t.jsx)(n.code,{children:"python -m app.jobs.v2_maintenance"}),"\n(daily in the scheduler); the transcript and the proof of what was transcribed stay. In the\nhosted edition every recording is metered through the Oddproof platform before capture or\nprocessing starts; a recording the payer cannot cover waits in the state ",(0,t.jsx)(n.code,{children:"pending_credit"}),"\nwith its audio kept until credit arrives."]}),"\n",(0,t.jsx)(n.h2,{id:"behavior-and-limits",children:"Behavior and limits"}),"\n",(0,t.jsx)(n.p,{children:"These tune capacity, retention, and the GDPR consent gate. All are optional and have working defaults."}),"\n",(0,t.jsxs)(n.table,{children:[(0,t.jsx)(n.thead,{children:(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.th,{children:"Variable"}),(0,t.jsx)(n.th,{children:"Default"}),(0,t.jsx)(n.th,{children:"What it controls"})]})}),(0,t.jsxs)(n.tbody,{children:[(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_REQUIRE_CONSENT"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"false"}
1)," (template sets ",(0,t.jsx)(n.code,{children:"true"}),")"]}),(0,t.jsxs)(n.td,{children:["When ",(0,t.jsx)(n.code,{children:"true"}),", ingest ",(0,t.jsx)(n.strong,{children:"refuses"})," any meeting that doesn't have recorded consent."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_RETENTION_DAYS"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"90"})}),(0,t.jsxs)(n.td,{children:["Meetings and their stored audio older than this are purged by the daily scheduler. Floor of ",(0,t.jsx)(n.code,{children:"1"})," \u2014 a zero/negative value can never wipe the whole dataset."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_MAX_CONCURRENT_CALLS"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"5"})}),(0,t.jsxs)(n.td,{children:["Maximum simultaneous ",(0,t.jsx)(n.strong,{children:"live"})," capture sessions across the whole box (admission cap)."]})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_UPLOAD_MAX_BYTES"})}),(0,t.jsxs)(n.td,{children:[(0,t.jsx)(n.code,{children:"314572800"})," (~300 MB)"]}),(0,t.jsx)(n.td,{children:"Size ceiling for a single uploaded audio/video file."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"MINUTES_UPLOAD_MAX_CONCURRENT"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"2"})}),(0,t.jsx)(n.td,{children:"Maximum file-transcription jobs processed at once by the scheduler."})]}),(0,t.jsxs)(n.tr,{children:[(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"LANDING_DIR"})}),(0,t.jsx)(n.td,{children:(0,t.jsx)(n.code,{children:"/opt/minutes-site"})}),(0,t.jsxs)(n.td,{children:["Host path of an optional marketing landing site served at ",(0,t.jsx)(n.code,{children:"/"}),". If the directory is missing, Caddy falls back to serving the app at ",(0,t.jsx)(n.code,{children:"/"}),"."]})]})]})]}),"\n",(0,t.jsx)(n.admonition,{title:"Two different defaults for consent",type:"note",children:(0,t.jsxs)(n.p,{children:["The application's built-in default for ",(0,t.jsx)(n.code,{children:"MINUTES_REQUIRE_CONSENT"})," is ",(0,t.jsx)(n.code,{children:"false"}),", but the shipped ",(0,t.jsx)(n.code,{children:".env.example"})," sets it to ",(0,t.jsx)(n.code,{children:"true"})," \u2014 the GDPR-safe choice. Only set it to ",(0,t.jsx)(n.code,{children:"false"})," if you have an alternative lawful basis for recording and understand that obligation. See ",(0,t.jsx)(n.a,{href:"/reference/limitations",children:"Limitations"}),"."]})}),"\n",(0,t.jsx)(n.admonition,{title:"Uploads need the user's own Soniox key",type:"note",children:(0,t.jsxs)(n.p,{children:["Audio upload (",(0,t.jsx)(n.code,{children:"audio/*"})," and ",(0,t.jsx)(n.code,{children:"video/*"})," containers, up to ",(0,t.jsx)(n.code,{children:"MINUTES_UPLOAD_MAX_BYTES"}),") is processed out-of-band by the scheduler, roughly every 20 seconds, and transcribes using the ",(0,t.jsx)(n.strong,{children:"uploader's own"})," Soniox key \u2014 not the server key."]})}),"\n",(0,t.jsx)(n.h2,{id:"domain-vs-ip-and-tls",children:"Domain vs. IP, and TLS"}),"\n",(0,t.jsxs)(n.p,{children:["A real ",(0,t.jsx)(n.strong,{children:"public domain is required"})," for any usable deployment, not just nice-to-have:"]}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:["The capture extension opens a ",(0,t.jsx)(n.strong,{children:"secure WebSocket"})," (",(0,t.jsx)(n.code,{children:"wss://"}),") from a secure browser context. Browsers refuse to open an insecure WebSocket from an HTTPS page."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"wss://"})," needs TLS, and Caddy gets TLS from Let's Encrypt, which can only issue a certificate for a real ",(0,t.jsx)(n.strong,{children:"domain"})," \u2014 never a bare IP."]}),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["So set ",(0,t.jsx)(n.code,{children:"DOMAIN"})," to a hostname whose DNS ",(0,t.jsx)(n.code,{children:"A"})," record points at the VPS, and make sure ",(0,t.jsx)(n.strong,{children:"inbound TCP 80 and 443 are reachable from the internet"})," (Let's Encrypt validates over them; Caddy serves HTTPS/",(0,t.jsx)(n.code,{children:"wss"})," there). No other port should be public."]}),"\n",(0,t.jsxs)(n.p,{children:["A bare IP or ",(0,t.jsx)(n.code,{children:"http://localhost"})," (plain ",(0,t.jsx)(n.code,{children:"ws://"}),", no TLS) works ",(0,t.jsx)(n.strong,{children:"only"})," for local development of the app itself \u2014 never for the extension against a server. Caddy auto-provisions and renews the certificate for ",(0,t.jsx)(n.code,{children:"$DOMAIN"}),"; there's nothing to configure beyond pointing DNS and opening the ports. See ",(0,t.jsx)(n.a,{href:"/admin/deploy",children:"Deploy"})," for the full DNS/firewall walkthrough."]}),"\n",(0,t.jsx)(n.h2,{id:"backups",children:"Backups"}),"\n",(0,t.jsx)(n.admonition,{title:"The default deployment ships no backups",type:"warning",children:(0,t.jsxs)(n.p,{children:["The single-box stack runs Postgres and MinIO on a single drive with ",(0,t.jsx)(n.strong,{children:"no replication, no failover, and n
1o automated backups"}),". A disk or host failure can lose data \u2014 an accepted trade-off for this low-cost showcase topology. If your data matters at all, add your own off-box backups."]})}),"\n",(0,t.jsxs)(n.p,{children:["A nightly Postgres dump via host ",(0,t.jsx)(n.code,{children:"cron"})," is the minimum. Add an entry like this on the host (run from your ",(0,t.jsx)(n.code,{children:"deploy/single-box"})," directory, or use absolute paths):"]}),"\n",(0,t.jsx)(n.pre,{children:(0,t.jsx)(n.code,{className:"language-bash",children:"# /etc/cron.d/minutes-backup \u2014 nightly Postgres dump, off-box\n0 3 * * * root cd /path/to/minutes/deploy/single-box && docker compose exec -T postgres pg_dump -U minutes minutes | gzip > /backups/minutes-$(date +\\%F).sql.gz\n"})}),"\n",(0,t.jsxs)(n.p,{children:["The ",(0,t.jsx)(n.code,{children:"-T"})," flag is required so ",(0,t.jsx)(n.code,{children:"docker compose exec"})," doesn't try to allocate a TTY under cron. Also mirror the audio bucket off-box (configure an external S3 or ",(0,t.jsx)(n.code,{children:"rsync"})," target) if archived audio matters to you. For the bigger picture on what the default deployment deliberately does ",(0,t.jsx)(n.em,{children:"not"})," do, see ",(0,t.jsx)(n.a,{href:"/reference/limitations",children:"Limitations"}),"."]}),"\n",(0,t.jsx)(n.h2,{id:"updating",children:"Updating"}),"\n",(0,t.jsxs)(n.p,{children:["To update to a new build, pull and rebuild from ",(0,t.jsx)(n.code,{children:"deploy/single-box"}),":"]}),"\n",(0,t.jsx)(n.pre,{children:(0,t.jsx)(n.code,{className:"language-bash",children:"git pull && docker compose up -d --build\n"})}),"\n",(0,t.jsxs)(n.p,{children:["The one-shot ",(0,t.jsx)(n.code,{children:"migrate"})," service re-runs ",(0,t.jsx)(n.code,{children:"alembic upgrade head"})," before the backend starts, so schema upgrades are applied automatically. Your ",(0,t.jsx)(n.code,{children:".env"})," is untouched \u2014 but watch the release notes for any new variables you may want to set."]}),"\n",(0,t.jsx)(n.h2,{id:"tuning-concurrency",children:"Tuning concurrency"}),"\n",(0,t.jsx)(n.p,{children:"The pipeline is I/O-bound \u2014 STT runs remotely at Soniox and translation remotely at Claude \u2014 so on the 2 vCPU / 8 GB box, concurrency is gated mainly by RAM and per-call audio work, not CPU."}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Live sessions:"})," if the box struggles under real load, lower ",(0,t.jsx)(n.code,{children:"MINUTES_MAX_CONCURRENT_CALLS"})," (e.g. to ",(0,t.jsx)(n.code,{children:"2"}),"\u2013",(0,t.jsx)(n.code,{children:"3"}),") in ",(0,t.jsx)(n.code,{children:".env"}),", then ",(0,t.jsx)(n.code,{children:"docker compose up -d"}),". The cap is enforced in Valkey/Redis and shared across the whole box."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"File uploads:"})," ",(0,t.jsx)(n.code,{children:"MINUTES_UPLOAD_MAX_CONCURRENT"})," (default ",(0,t.jsx)(n.code,{children:"2"}),") bounds how many uploaded files transcribe at once; lower it to ease memory pressure."]}),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["Per-service memory limits (Postgres 1.5 GB, MinIO 1 GB, backend 1 GB, and so on) are set in ",(0,t.jsx)(n.code,{children:"docker-compose.yml"}),", not in ",(0,t.jsx)(n.code,{children:".env"}),"."]}),"\n",(0,t.jsx)(n.admonition,{title:"Scaling past one box",type:"tip",children:(0,t.jsxs)(n.p,{children:["The admission cap is already Redis-shared, so running more than one backend replica is safe \u2014 but it needs a Caddy upstream change to load-balance across containers. Beyond that, outgrowing the single box means moving Postgres, Redis, and object storage to managed/replicated services; the application code is unchanged. See ",(0,t.jsx)(n.a,{href:"/reference/limitations",children:"Limitations"}),"."]})})]})}function a(e={}){const{wrapper:n}={...(0,i.R)(),...e.components};return n?(0,t.jsx)(n,{...e,children:(0,t.jsx)(h,{...e})}):h(e)}},8453(e,n,s){s.d(n,{R:()=>d,x:()=>o});var r=s(6540);const t={},i=r.createContext(t);function d(e){const n=r.useContext(i);return r.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function o(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(t):e.components||t:d(e.components),r.createElement(i.Provider,{value:n},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.