OpenMAIC

Deployment

Deploy OpenMAIC with Docker, or with pnpm start on your own host.

OpenMAIC is a Next.js application that needs a long-running Node.js process and PostgreSQL. Course generation runs on the server: each run is stored in the database and executed by a worker inside the server process, which keeps going after the browser leaves the page and is taken over by another process (or the same one after a restart) from its last completed step. Two deployments are supported:

  • Docker Compose (recommended): the app and PostgreSQL in two containers.
  • The Docker image or pnpm start with a PostgreSQL database you run yourself.

Serverless hosts such as Vercel are not supported from 1.2.0: a function that ends with its request cannot run a generation. See Vercel (up to 1.1.x).

Docker

The repository includes a production-ready Dockerfile. The image uses Node.js 22 internally. Build and run it with:

docker build -t openmaic .

docker run --env-file .env.local -e DATABASE_URL=postgres://user:password@db-host:5432/openmaic -v openmaic-data:/app/data -p 3000:3000 openmaic

We recommend the repository's Docker Compose configuration:

cp .env.example .env.local
# Edit .env.local (keys and server options), then:
docker compose up --build

The default Compose deployment starts two containers, the OpenMAIC app and PostgreSQL, wires DATABASE_URL between them, and mounts the openmaic-data and openmaic-postgres volumes. Configure other providers and features as needed; see Configuration.

Models are configured in one of two ways. For a personal installation, start the stack, open the app and connect a model service in the model settings; the configuration is stored in the database. For a deployment you manage as files, copy openmaic.example.yml to openmaic.yml, keep the keys in .env.local and mount the file (see below). See Configuration.

The image creates the volume's data directory with the runtime user's ownership, so fresh volumes persist classrooms and usage records out of the box. A volume created by an older image may be root-owned, which makes every server-side save fail with EACCES while the app keeps serving normally (classrooms silently never reach disk; the log shows Failed to record usage ... EACCES: permission denied). Repair such a volume once, then restart the app:

docker exec -u root <container> chown -R 1001:1001 /app/data

NEXT_PUBLIC_* feature flags are injected at Docker build time and cannot be set only in the runtime .env.local. For example, to enable video export and experimental PPTX import:

NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true \
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true \
docker compose --profile video-export up --build

Other client-side feature flags can be passed in the same way. When using docker build, use the corresponding --build-arg options instead.

To use openmaic.yml, mount it at the app's working directory, or anywhere else with OPENMAIC_CONFIG pointing at it:

services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

Do not mount a path that does not exist on the host: Docker creates a directory in its place, and the server refuses to start. With docker run, add -v "$PWD/openmaic.yml:/app/openmaic.yml:ro". Restart the container after editing the file. openmaic.yml is listed in .dockerignore, so it is never baked into an image built from the repository: mount it at run time instead. The legacy server-providers.yml is still read from /app/server-providers.yml when there is no openmaic.yml.

Inside a Docker container, localhost refers to the container itself. If Ollama, Lemonade, VoxCPM, or ComfyUI runs on the host, use host.docker.internal, for example http://host.docker.internal:11434/v1 or http://host.docker.internal:8188. Linux Docker usually also requires extra_hosts: ["host.docker.internal:host-gateway"] on the openmaic service. Providers declared in openmaic.yml (or through the legacy variables) are server-managed, so their endpoints may be on the host or a private network without ALLOW_LOCAL_NETWORKS. That variable only matters for endpoints a user types: chat base URLs added in Settings → Model Services, and the deprecated request fields.

Self-host on a VM

The host needs Node.js 22.19.0 or later and pnpm 10.28.0. Build and start with pnpm:

pnpm install
pnpm build
DATABASE_URL=postgres://user:password@db-host:5432/openmaic pnpm start      # listens on port 3000 by default

Run it under a process manager (systemd, pm2, a container) that keeps it running and restarts it, and put nginx or Caddy in front for TLS termination. Courses and generated course media are stored in PostgreSQL (or S3), so several instances can share one database, as long as they also share the data directory and OPENMAIC_SECRET_KEY (see below).

pnpm start runs next start, which warns that it does not work with the output: standalone setting. The warning is harmless: next start serves the complete build from .next. The Docker image runs the standalone server instead (node server.js), but that server changes its working directory to its own folder, so on a VM keep pnpm start, which reads data/ and openmaic.yml from the directory it starts in.

Required configuration

SettingDocker ComposeDocker image or pnpm start
DATABASE_URLset for the bundled PostgreSQLrequired; the server exits at startup without it
Identity modesingle user (OWNER_SINGLE_USER=true in docker-compose.defaults.env)one anonymous owner per browser unless you choose one
OPENMAIC_SECRET_KEYoptional: a generated secret is kept in the openmaic-data volumerequired for several instances, or without a persistent data/ directory
Data directorythe openmaic-data volume at /app/dataa persistent data/ directory in the working directory, shared by every instance
Modelsopenmaic.yml or Settings → Modelsthe same

Identity mode decides who owns a course. Without one, every browser is its own anonymous owner and a second browser sees an empty library. Set OWNER_SINGLE_USER=true for a personal installation, PERSISTENCE_SHARED_OWNER_ID together with ACCESS_CODE for one team sharing one library, or register owner auth methods for real accounts; see Owner identity and .env.example.

In anonymous mode the owner cookie carries Secure in production, and so does the ACCESS_CODE cookie. Browsers keep a Secure cookie only over HTTPS or on http://localhost (Safari not even there), so a deployment served over plain HTTP must set COOKIE_SECURE=0 (exactly 0): otherwise every request becomes a new owner, writes fail with 403, and the access code is asked for again. Without TLS the cookies travel in the clear, so do this only on a trusted network.

Plain HTTP on any address other than localhost (a LAN IP, a Tailscale IP or MagicDNS name) is also not a secure context, so the browser has no Web Locks API. OpenMAIC still saves there, but tabs of the same course are not coordinated: if two tabs change the same chat, the last save wins, and a chat deleted in one tab stays deleted even if another tab was still editing it. For a phone or tablet on your network, prefer HTTPS, for example tailscale serve in front of the server.

OPENMAIC_SECRET_KEY encrypts the provider keys saved in the model settings. Without it, each instance creates its own secret in data/instance-secret.key, so instances sharing a database cannot read each other's saved keys. Keep it with the database backups; see Secrets and OPENMAIC_SECRET_KEY.

Several instances that share a database also need one shared, persistent data directory (data/ in the working directory, /app/data in the image; for example the same volume or network file system on every instance). Course documents and generated course media live in PostgreSQL (or S3 with ASSET_S3_BUCKET), but each process keeps these files in its data directory: uploaded material files and their extraction results, the narration and material media of courses the agent edits (data/classrooms, or OPENMAIC_CLASSROOMS_DIR), usage records (data/usage) and the generated instance secret. A generation run that another instance takes over reads its uploaded materials from there.

Generation runs

Every server process runs a generation worker; it does not depend on OPENMAIC_AGENT_RUNTIME_ENABLED. These variables tune it (defaults shown, positive integers):

VariableDefaultMeaning
OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER2Runs one owner may have in progress. A run waiting for its outline to be confirmed, or paused at a failed step, does not count. Over the limit, starting, confirming or retrying answers 429 ACTIVE_RUN_LIMIT.
OPENMAIC_MAX_WAITING_RUNS_PER_OWNER10Runs one owner may have waiting for outline confirmation.
OPENMAIC_GENERATION_RUN_MAX_CONCURRENT4Runs one process executes at once.
OPENMAIC_GENERATION_RUN_STREAMS_PER_OWNER16Run event streams one owner may hold open on one process.
OPENMAIC_GENERATION_RUN_RETENTION_HOURS24How long a finished run keeps its full event log before it is compacted.

Runs share the agent runtime's lease timing (OPENMAIC_AGENT_RUNTIME_SCAN_INTERVAL_MS, _HEARTBEAT_MS, _LEASE_TTL_MS, _MAX_ATTEMPTS; see .env.example). When a server stops or restarts, a rolling update included, its runs continue on another instance, or on the same one when it starts again, from their last finished step: progress pauses for about the lease TTL (10 seconds by default) and the step that was in progress is generated again. A run interrupted OPENMAIC_AGENT_RUNTIME_MAX_ATTEMPTS times in a row (5 by default) without finishing a step pauses at that step with Retry; raise it if rolling updates over many instances reach it.

Server-side persistence (PostgreSQL)

OpenMAIC stores courses, chat history, learner progress and generated media in PostgreSQL, and DATABASE_URL is required: without it the server prints [boot] Invalid server configuration; the server will not start: DATABASE_URL is not set. ... with the fix and exits with code 1. The persistence HTTP API is embedded in OpenMAIC; no separate persistence service is required.

docker compose up starts PostgreSQL next to the app and sets DATABASE_URL for it. For local development, pnpm db:up starts a separate development database from the same service definition (its own Compose project and volume, openmaic-dev-db, shared by every checkout on this machine, so it never touches the stack's database), published on 127.0.0.1 (port OPENMAIC_DB_PORT, default 5432); pnpm db:down stops it again. Then set the connection in .env.local:

DATABASE_URL=postgres://openmaic:openmaic-dev@127.0.0.1:5432/openmaic

There is no separate persistence credential: every request is attributed to the owner that owner resolution returns (by default an anonymous per-browser cookie), and runtime sessions and media are partitioned by that owner. The default identity isolates browsers but authenticates nobody, so a deployment serving real users should register owner auth methods backed by its own accounts. Earlier releases used PERSISTENCE_DEV_TOKEN / NEXT_PUBLIC_PERSISTENCE_TOKEN; both are removed and ignored, and runtime sessions stored under the old browser-minted learner key are no longer reachable. If PERSISTENCE_DEV_TOKEN was your only access gate, put the deployment behind ACCESS_CODE or a gateway, or register owner auth methods, before upgrading.

PostgreSQL data is stored in the openmaic-postgres volume. PERSISTENCE_POSTGRES_PASSWORD sets the password only when the database directory is first initialized. Changing the environment variable later does not automatically change the existing database user's password.

The openmaic-data volume also holds instance-secret.key, the generated instance secret that encrypts provider keys saved in the model settings, unless OPENMAIC_SECRET_KEY is set. Back it up together with the database: without it, saved keys have to be entered again. Running several replicas, or the app without a persistent volume or on a read-only filesystem? Set OPENMAIC_SECRET_KEY instead (the same value on every replica); see Secrets and OPENMAIC_SECRET_KEY.

Earlier releases could keep courses in the browser (IndexedDB). That mode is gone, and the NEXT_PUBLIC_PERSISTENCE build variable is ignored. Courses a browser stored that way move to the server automatically the first time that browser opens the upgraded app: a one-way importer copies them (with their chat, learner progress, folders and media) to the owner the server resolves for that browser, and leaves the browser copy untouched. It runs once per browser: the server binds the browser to the first owner that loads the upgraded app, and a claim carries the binding to the account (an anonymous owner claimed into an account at sign-in). The server refuses the importer's requests for any other owner, so another owner that later uses the same browser gets nothing imported. It needs no action and shows no UI; problems are logged in the browser console under [legacy-browser-import].

Upgrading from 1.1.x

DeploymentDo
docker composePull the new release and run docker compose up --build. Move model settings to openmaic.yml or Settings → Models (provider variables keep working while there is no openmaic.yml), and rewrite MODEL_ROUTES as slots: a server that sets it without openmaic.yml refuses to start. Open the app once in each browser that has local courses, so they are imported. If .env.local sets PERSISTENCE_SHARED_OWNER_ID, also set OWNER_SINGLE_USER=false: Compose now starts in single-user mode. Courses an earlier server-backed deployment kept for anonymous owners are not merged into the single user automatically; claim them with POST /api/identity/claim from each browser that created them (opening a browser imports only what it kept locally).
pnpm start with your own PostgreSQLStop the server, pull the new release, run pnpm install and pnpm build, and start it again with pnpm start in the same working directory, so it keeps data/. Set DATABASE_URL, an identity mode, and OPENMAIC_SECRET_KEY when several instances share the database; several instances also need one shared data directory (see Required configuration). Model settings, MODEL_ROUTES and browser courses as in the row above.
VercelStay on 1.1.x, or move to a long-running host with the same database (below).
Skill / APIFollow the updated openmaic skill: upload materials, submit, and poll as the same owner.

Before the first start of 1.2.0:

  • Keep the data directory. Start 1.2.0 with the same data/ in its working directory (or the same OPENMAIC_CLASSROOMS_DIR), or the same openmaic-data volume with Compose. Classrooms that 1.1.x saved as files under data/classrooms are imported into the database once, in the background after startup; a server started without that directory has nothing to import.
  • Set the identity mode first. The import gives those classrooms to the owner configured at that start: the single user (OWNER_SINGLE_USER=true) or the shared team owner (PERSISTENCE_SHARED_OWNER_ID), and otherwise (anonymous mode) the dedicated owner system:legacy-classrooms, under which they open read-only from their old links and appear in no library. Each classroom is imported only once, so classrooms imported in anonymous mode stay with system:legacy-classrooms even if you set an identity mode later.
  • List the models. Every provider with server-managed credentials that sends a selected model upstream (chat, image, video, speech, transcription, model-based web search) needs an explicit model list (*_MODELS, or models under the provider in openmaic.yml or server-providers.yml). Without one, only the default, fallback and slot models are authorized, and the startup log names each such provider that has no list. Services without model selection, such as Tavily or a document parser, need no list. See Model authorization.
  • Upgrade the render-service with the app. If you run MP4 video export, upgrade every render-service instance together with the app or before it: render jobs are bound to their owner, and a mix of old and new versions fails closed.
  • Check the classroom model. Pi classroom chat refuses a model that the built-in catalogue lists without tool calling (tool_calling_required) and Google's OpenAI-compatible Gemini endpoint (gemini_native_required). Configure Gemini with the native google preset; see LLM providers.

Browser data (courses, model settings, custom agents) is imported once per browser, the first time that browser opens the upgraded app. See Configuration for moving model settings and the changelog for the full list of breaking changes.

Vercel (up to 1.1.x)

Serverless deployment is supported up to OpenMAIC 1.1.x, and the repository README describes how to deploy the release/1.1.x branch on Vercel. Such a deployment can move to a long-running host later without losing data: point the new host's DATABASE_URL at the database it used, if it used one, and serve it at the same address: browsers keep their data per site, and the courses visitors kept there are imported the first time each browser opens the upgraded app.

Optional: MP4 video export

The “Export video” feature first generates a self-contained Hyperframes project in the browser, then sends it to a separate render-service that uses Chromium and FFmpeg to render an MP4. The service is optional and does not affect normal classroom generation.

Enable the video-export profile:

docker compose --profile video-export up --build

Compose connects OpenMAIC to the rendering service through RENDER_SERVICE_URL. If the profile is disabled or the rendering service is unavailable, export falls back to downloading the project ZIP for rendering with the local CLI. The rendering service uses an isolated network and requires the NET_ADMIN capability at startup. See render-service/README.md for more limitations and standalone deployment instructions. When upgrading, upgrade the render-service together with the app or before it.

Environment variables

See Configuration for model configuration (openmaic.yml and the model settings) and every environment variable. At least one chat model must be configured before courses can be generated.

Access control

Shared demos can use ACCESS_CODE to put a password gate in front of the entire site. See Configuration → ACCESS_CODE.

Optional self-hosted services

On this page