--- title: Deployment description: 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)](#vercel-up-to-11x). ## Docker The repository includes a production-ready `Dockerfile`. The image uses Node.js 22 internally. Build and run it with: ```bash 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: ```bash 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](./configuration.mdx). 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](./configuration.mdx#models-and-providers). 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: ```bash docker exec -u root 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: ```bash 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: ```yaml 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`: ```bash 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 | Setting | Docker Compose | Docker image or `pnpm start` | | --- | --- | --- | | `DATABASE_URL` | set for the bundled PostgreSQL | required; the server exits at startup without it | | Identity mode | single user (`OWNER_SINGLE_USER=true` in `docker-compose.defaults.env`) | one anonymous owner per browser unless you choose one | | `OPENMAIC_SECRET_KEY` | optional: a generated secret is kept in the `openmaic-data` volume | required for several instances, or without a persistent `data/` directory | | Data directory | the `openmaic-data` volume at `/app/data` | a persistent `data/` directory in the working directory, shared by every instance | | Models | `openmaic.yml` or **Settings → Models** | the 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](./hosting.mdx#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](./configuration.mdx#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): | Variable | Default | Meaning | | --- | --- | --- | | `OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER` | `2` | Runs 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_OWNER` | `10` | Runs one owner may have waiting for outline confirmation. | | `OPENMAIC_GENERATION_RUN_MAX_CONCURRENT` | `4` | Runs one process executes at once. | | `OPENMAIC_GENERATION_RUN_STREAMS_PER_OWNER` | `16` | Run event streams one owner may hold open on one process. | | `OPENMAIC_GENERATION_RUN_RETENTION_HOURS` | `24` | How 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`: ```bash 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](./configuration.mdx#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 | Deployment | Do | | --- | --- | | `docker compose` | Pull 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 PostgreSQL | Stop 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](#required-configuration)). Model settings, `MODEL_ROUTES` and browser courses as in the row above. | | Vercel | Stay on 1.1.x, or move to a long-running host with the same database (below). | | Skill / API | Follow 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](./configuration.mdx#model-authorization-for-server-managed-providers). - **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](./configuration.mdx#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](./configuration.mdx#migrating-from-the-legacy-configuration) for moving model settings and the [changelog](https://github.com/THU-MAIC/OpenMAIC/blob/main/CHANGELOG.md) 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](https://github.com/THU-MAIC/OpenMAIC#vercel-deployment-up-to-11x) 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: ```bash 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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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](./configuration.mdx) 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](./configuration.mdx#access_code--site-wide-password). ## Optional self-hosted services - [VoxCPM2: self-hosted TTS and voice cloning →](./voxcpm.mdx)