Heym provides two scripts for different environments: run.sh for local development and deploy.sh for production deployments using Docker Compose.
Video walkthrough: Set Up Heym Locally in Under 2 Minutes — clone the repository, start PostgreSQL, fill in the required environment variables, and create your first account on a local instance.
Prerequisites
Both scripts require the following tools to be installed:
| Tool | Purpose |
|---|---|
| Docker | Database container (dev) and full stack (prod) |
| uv | Python package manager for the backend |
| bun | JavaScript runtime for the frontend |
Environment Setup
Both run.sh and deploy.sh read from a .env file in the project root. If it does not exist, the script automatically creates one from .env.example — for both local development and production.
SECRET_KEY and ENCRYPTION_KEY ship empty in .env.example. When either is empty, run.sh and deploy.sh generate a cryptographically strong value automatically and write it back to .env, so a fresh setup needs no manual key handling. If an existing .env still contains the legacy ENCRYPTION_KEY placeholder (change_this_to_a_random_32_byte_hex_value), the scripts stop with an explicit error instead of overwriting it — rotating that key would make previously-encrypted credentials unreadable. The backend itself also refuses to start if either key is empty or left at a known placeholder.
To create the file manually (optional — the scripts do this for you):
cp .env.example .envKey environment variables:
For every supported variable, default, and production note, see Environment Variables.
| Variable | Description |
|---|---|
SECRET_KEY | Required. JWT signing secret. Auto-generated by run.sh/deploy.sh when empty. |
ENCRYPTION_KEY | Required. Credential encryption key. Auto-generated by run.sh/deploy.sh when empty. |
DATABASE_URL | Optional database connection string override. If empty, Heym builds it from POSTGRES_* settings. |
BACKEND_PORT | Backend API port — defaults to 10105 |
FRONTEND_PORT | Frontend port — defaults to 4017 |
FRONTEND_URL | Required in production. Public URL of the app (scheme + host, e.g. https://heym.example.com). Used for OAuth redirect URIs (Google Sheets, BigQuery, Notion, and similar), and for the review, Codex follow-up and file links that background runs mint; must match the URL users use in the browser. In a cluster, set the same value on every instance. |
ALLOW_REGISTER | Open user registration (false in prod, true in dev). Flip it to false only after your admin account exists — there is no first-user bootstrap, so an empty database plus disabled registration leaves no way to create one. |
DOCKER_LOGS_ENABLED | Enables Docker-backed Logs tab access when set to true; also requires Docker socket access |
DOCKER_LOGS_ALLOWED_EMAILS | Comma-separated list of trusted user emails allowed to access Docker logs when DOCKER_LOGS_ENABLED=true |
REQUEST_BODY_MAX_SIZE_MB | Maximum HTTP request body size accepted before endpoint handlers run; defaults to 100, one MB above FILE_MAX_SIZE_MB to leave room for multipart overhead |
HEYM_PYTHON_TOOL_SANDBOX | How user-defined Python tools run: auto (default — hardened, isolated Docker container; fail closed if Docker is unavailable), docker (same, never falls back), or subprocess (in-process local fallback; not a security boundary, trusted/dev only). run.sh sets subprocess for native dev. See Security. |
HEYM_PYTHON_TOOL_IMAGE | Image used for the Python tool Docker sandbox. Empty = auto-detect the running backend image. |
Database connection defaults (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB) are documented in Environment Variables and .env.example.
Development: run.sh
run.sh starts all three services locally — the database (as a Docker container), the FastAPI backend, and the Vite dev server — with a single command.
./run.shWhat it does, step by step:
- Checks that
docker,bun, anduvare available - Creates
.envfrom.env.exampleif missing, and generates a randomSECRET_KEYandENCRYPTION_KEYwhen either is empty - Starts (or creates) a database Docker container on port
6543, storing its data in theheym-postgres-dataDocker volume rather than a host directory - Installs Python dependencies via
uv sync - Runs Alembic database migrations (
alembic upgrade head) - Frees the backend and frontend ports if occupied
- Starts the FastAPI backend with
--reload(hot-reload on code changes) - Installs frontend dependencies via
bun install - Starts the Vite dev server
Options:
| Flag | Description |
|---|---|
| (none) | Start with debug logging enabled (LOG_LEVEL=DEBUG) |
--no-debug | Start with default log level (no debug output) |
--help | Show usage information |
Service addresses (dev):
| Service | Address |
|---|---|
| Frontend | localhost on FRONTEND_PORT (default: 4017) |
| Backend API | localhost on BACKEND_PORT (default: 10105) |
| Interactive API Docs | localhost:10105/docs |
| Database | localhost:6543 |
Press Ctrl+C to gracefully stop all services.
End-to-End Tests: run_e2e.sh
The frontend Playwright suite runs against the real Vue application, FastAPI backend, and an isolated PostgreSQL database:
./run_e2e.shThe suite is a key-path smoke and regression suite, not exhaustive product coverage. It covers authentication, core workflow lifecycle and execution, selected dashboard resources, public routes, and a mocked HITL review UI. Provider integrations, every node type, sharing/permission matrices, and full HITL resume execution remain covered primarily by backend tests or require dedicated integration environments.
Each run gets its own temporary PostgreSQL 16 container, random available database/backend/frontend
ports, authentication state, test results, and HTML report. This allows multiple run_e2e.sh
processes to run concurrently without sharing state or overwriting artifacts. The script removes
its database container when the run finishes and prints the artifact directory and report path.
Useful commands:
./run_e2e.sh --ui # Interactive Playwright runner with an isolated database
cd frontend
bun run test:e2e:report # Open the most recently completed local runDirect bun run test:e2e and bun run test:e2e:ui runs require an explicit DATABASE_URL so they
cannot silently start against the local development database. Use ./run_e2e.sh for normal local
runs.
./check.sh runs lint, typecheck, formatting, and backend tests without the E2E suite, keeping the
default local check path fast. Run Playwright E2E tests separately with ./run_e2e.sh. Pull
requests always run the Chromium E2E suite in GitHub Actions and retain traces, screenshots,
videos, and the HTML report when failures occur.
Production: deploy.sh
deploy.sh builds and runs the full stack using Docker Compose. All three services — the database, the backend, and the frontend — run as containers. The backend entrypoint automatically runs migrations before starting the server with 8 workers.
Initial deploy (build + start):
./deploy.shThis performs a zero-downtime deploy: images are built first while the existing containers keep running, then the new version is swapped in.
Available commands:
| Command | Description |
|---|---|
./deploy.sh | Build images and start/update all services |
./deploy.sh --status | Show container status |
./deploy.sh --logs | Stream logs from all containers |
./deploy.sh --restart | Restart all containers |
./deploy.sh --down | Stop and remove all containers |
./deploy.sh --migrate-pgdata | One-time migration: copy an existing data/postgres directory into the heym-postgres-data Docker volume and rebuild its indexes |
./deploy.sh --help | Show usage information |
Database storage: a Docker named volume, not a host directory.
Compose stores PostgreSQL in the heym-postgres-data Docker volume. Earlier versions bind-mounted the host directory ./data/postgres instead. Host bind mounts on macOS (virtiofs) and Windows/WSL2 do not give PostgreSQL the fsync and file-close guarantees it requires: clusters there hit PANIC: could not close file … Input/output error on WAL segments and corrupt over time. Linux bind mounts were never affected, but every platform now uses the same layout.
Upgrading a deployment that still has data in ./data/postgres is a one-time step. ./deploy.sh detects it, stops before building, and asks you to migrate first:
./deploy.sh --migrate-pgdata # stop services, copy into the volume, rebuild indexes
./deploy.sh # deploy as usualThe migration also rebuilds indexes. The copy is byte-for-byte, so index damage the old bind mount caused travels with it. --migrate-pgdata therefore starts the postgres service alone on the new volume, runs reindexdb against it, and stops it again before the rest of the stack comes up. This matters because this class of corruption leaves index entries pointing past the end of the heap — a state pg_amcheck reports as clean, so a passing check is not evidence of a healthy table. REINDEX repairs those entries; it cannot restore heap pages the old filesystem already lost. On a large, known-healthy database you can bypass the rebuild with ./deploy.sh --migrate-pgdata --skip-reindex.
The copy is non-destructive. ./data/postgres stays in place as a backup, and the migration refuses to overwrite a volume that already holds a database. If any step fails, the new volume is removed, so a half-migrated cluster can never be deployed by mistake. Delete the old directory once you have confirmed your workflows are intact. The postgres container carries the same guard, so a manual docker compose up cannot silently initialise an empty database over your data either. Fresh installs need no action.
Service addresses (production):
The frontend container is exposed on FRONTEND_PORT (default: 4017). The backend API is served under the /api path, proxied through the frontend container — so there is only one public-facing port in production.
Container overview:
| Container | Description |
|---|---|
heym-db | Relational database |
heym-backend | FastAPI API server (8 workers, built from backend/Dockerfile) |
heym-frontend | Frontend preview container serving the built Vue app (built from frontend/Dockerfile) |
Version update badge:
The app header shows the running Docker build version. When that version is behind the latest Heym GitHub release, a purple Update badge appears next to the version. Click the version or badge to open the Heym GitHub releases page.
Prebuilt Image: docker run
If you prefer not to build the app locally, you can pull the published container image and run it directly.
The image starts the frontend and backend together in one container. PostgreSQL is still external, but you can provide either DATABASE_URL or the POSTGRES_* variables from .env.example.
Store your database in a Docker named volume. The release image ships no database, so the PostgreSQL behind
DATABASE_URLis yours to run. Mount it as-v heym-postgres-data:/var/lib/postgresql/datarather than a host path: on macOS (virtiofs) and Windows/WSL2 a bind-mounted PostgreSQL data directory does not honour thefsyncguarantees the database requires and corrupts the cluster over time.
Vector store backend.
run.shanddeploy.shrun the officialpostgres:16image and auto-install thepostgresql-16-pgvectorpackage at startup, so the Postgres (pgvector) RAG backend works out of the box there with no change to your data. The prebuilt single-container image, however, connects to a PostgreSQL you provide — that database must support thevectorextension to use the Postgres backend. Heym cannot install pgvector into a database it does not manage. Without it, the startup migration skips the pgvector table gracefully — the deploy still succeeds, Qdrant RAG keeps working, and creating or uploading to a Postgres vector store returns a clear "backend unavailable" message until pgvector is enabled.
Set the keys yourself for direct image runs. Unlike run.sh/deploy.sh, the prebuilt image does not auto-generate keys. After cp .env.example .env, populate the two empty keys (replacing in place avoids duplicate entries):
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
ENCRYPTION_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
sed -i.bak "s|^SECRET_KEY=.*|SECRET_KEY=${SECRET_KEY}|; s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|" .env && rm -f .env.bakdocker pull ghcr.io/heymrun/heym:latest
docker run --rm \
--env-file .env \
-p 4017:4017 \
--shm-size 2g \
-e FILE_STORAGE_DIR=/app/data/files \
-e HEYM_PLUGINS_DIR=/app/data/plugins \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)/data/files:/app/data/files" \
-v "$(pwd)/data/plugins:/app/data/plugins" \
-v heym-codex-workspaces:/app/data/codex-workspaces \
-v heym-opencode-workspaces:/app/data/opencode-workspaces \
ghcr.io/heymrun/heym:latestMake the file and plugin mounts absolute.
FILE_STORAGE_DIRandHEYM_PLUGINS_DIRdefault to the relative paths./data/filesanddata/plugins, and the release image starts the backend from/app/backend. Left relative, they resolve to/app/backend/data/filesand/app/backend/data/plugins, so the two bind mounts above receive nothing and uploads are lost when the container is replaced. The-eflags override the relative values.env.examplesupplies through--env-file, because command-line-ewins over--env-file. Compose (./deploy.sh) does not need this: that image runs the backend from/app, where the relative defaults already land on the mounts.
Docker socket access. Mounting
/var/run/docker.sockgives the backend broad control over the host Docker daemon. The default Docker Compose service and the directdocker runexample include it for Docker-based MCP stdio tools that rundockercommands. The Logs tab still requiresDOCKER_LOGS_ENABLED=trueandDOCKER_LOGS_ALLOWED_EMAILSwith a comma-separated list of trusted user emails. Create the trusted admin account before enabling Docker logs, or keepALLOW_REGISTER=false, so an unverified self-registration cannot claim an allow-listed email. User-defined Python tools do not get this socket: they run in a separate hardened container with no Docker socket. See Security.
Codex runner. The Codex node uses the same
ghcr.io/heymrun/heymimage as a sibling runner container (--entrypoint codex) so Codex's Linux sandbox can create namespaces. Keep theheym-codex-workspacesvolume mount if you want Codex workflows in the direct image setup; the runner does not receive the Docker socket or backend secrets.
OpenCode Go runner. The OpenCode Go node uses the same image as a hardened sibling runner (
--entrypoint opencode) sharingheym-opencode-workspaces. Keep that volume mount if you want OpenCode workflows in the direct image setup; the runner does not receive the Docker socket or the GitHub token.
Skill sandbox. Python skills on the Agent node run in a hardened sibling container that shares the
heym-codex-workspacesvolume, so keep that volume mount for skills too — not just Codex. Each run gets an isolated per-run subpath, and the sibling receives neither the Docker socket nor backend secrets. This needs Docker Engine 25.0+; on older engines, or without the volume,HEYM_PYTHON_TOOL_SANDBOX=autofails closed — setHEYM_PYTHON_TOOL_SANDBOX=subprocessonly for trusted single-user setups. See Security.
--shm-size 2gis required for Playwright. Docker gives a container 64 MB of/dev/shmand Chromium crashes its renderer under that. Step-based Playwright nodes run Chromium as a subprocess inside this container, so the limit is this container's; customplaywrightCoderuns in a sibling container that sets its own.docker-compose.ymlalready declaresshm_size: "2gb", sorun.shanddeploy.share unaffected — only a plaindocker runneeds the flag.
Playwright Run Code. Custom Playwright Python needs
HEYM_PLAYWRIGHT_CUSTOM_CODE_ENABLED=trueand the Docker socket mount above. The release image setsHEYM_PLAYWRIGHT_SANDBOX_IMAGE/HEYM_PLAYWRIGHT_SANDBOX_PYTHONfor the GHCR layout (/app/backend/.venv). Compose./deploy.shdefaults the sandbox image toheym-backend:local. Keep--no-sandboxin Chromium launch args inside sandbox containers.
Minimum environment variables for direct image runs:
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Optional | Full PostgreSQL connection string override |
POSTGRES_HOST | Yes, if DATABASE_URL is empty | PostgreSQL host |
POSTGRES_PORT | Yes, if DATABASE_URL is empty | PostgreSQL port |
POSTGRES_USER | Yes, if DATABASE_URL is empty | PostgreSQL username |
POSTGRES_PASSWORD | Yes, if DATABASE_URL is empty | PostgreSQL password |
POSTGRES_DB | Yes, if DATABASE_URL is empty | PostgreSQL database name |
SECRET_KEY | Yes | JWT signing secret |
ENCRYPTION_KEY | Yes | Credential encryption key |
FRONTEND_URL | Recommended | Public browser URL, especially for OAuth callbacks |
CORS_ORIGINS | Recommended | Allowed browser origins |
FILE_STORAGE_DIR | Recommended | Set it to an absolute /app/data/files when you mount that path; the relative ./data/files default resolves under /app/backend in this image and misses the mount |
ALLOW_REGISTER | Recommended | Set false in production unless open signup is intended, but only once your admin account exists — there is no first-user bootstrap |
DOCKER_LOGS_ENABLED | Optional | Set true to allow the Logs tab to use Docker socket access |
DOCKER_LOGS_ALLOWED_EMAILS | Required when DOCKER_LOGS_ENABLED=true | Comma-separated list of trusted user emails allowed to access Docker logs |
HEYM_PLUGINS_ENABLED | Optional | Set true to enable the plugin subsystem (custom nodes installed as zip). Off by default |
HEYM_PLUGIN_ADMIN_EMAILS | Required when HEYM_PLUGINS_ENABLED=true | Comma-separated operator emails allowed to install/uninstall plugins |
HEYM_PLUGINS_DIR | Optional | Where plugin files are stored. Set it to an absolute /app/data/plugins when you mount that path; the relative data/plugins default resolves under /app/backend in this image and misses the mount |
HEYM_PYTHON_TOOL_SANDBOX | Optional | Python tool isolation mode; defaults to auto (hardened Docker sandbox, fail closed). See Security |
HEYM_PYTHON_TOOL_IMAGE | Optional | Override the Python tool sandbox image; empty = auto-detect the backend image |
HEYM_CODEX_DOCKER_WORKSPACE_VOLUME | Optional | Docker volume used by sibling Codex runner containers; defaults to heym-codex-workspaces in Docker deployments |
HEYM_CODEX_NETWORK_ACCESS | Optional | Allow Codex's sandboxed commands to download files/dependencies; Docker deployments enable this by default |
HEYM_OPENCODE_DOCKER_WORKSPACE_VOLUME | Optional | Docker volume used by sibling OpenCode runner containers; defaults to heym-opencode-workspaces in Docker deployments |
Notes:
- The image exposes port
4017 - The backend stays internal and is proxied under
/api - When
POSTGRES_HOST=localhost, the release image automatically rewrites it tohost.docker.internalwhen needed so the same.envworks with a host-level PostgreSQL container on macOS Docker/Desktop tools - Keep the
data/filesmount if you want Drive uploads and skill-generated files to survive container restarts - Plugins: to enable them, set
HEYM_PLUGINS_ENABLED=trueandHEYM_PLUGIN_ADMIN_EMAILS, and mountdata/pluginsso installed plugin files persist across container recreates. Plugin metadata lives in your PostgreSQL, so it also survives. A plugin's declared pipdependenciesare installed into the container at install time; because the image filesystem is ephemeral, they are reinstalled automatically on startup for every installed plugin (the release image'suvvenv is writable, so this works in the single-container image too)
Moving an existing database to a named volume
./deploy.sh --migrate-pgdata only knows about the Compose stack. If you run the release image against your own PostgreSQL container and that container bind-mounts a host directory, migrate it yourself.
Check what your database container uses first:
docker inspect <your-postgres-container> | grep -A8 MountsIf "Type" reads bind, move it to a named volume:
# 1. Stop the database. Heym can stay down for this.
docker stop <your-postgres-container>
docker rm <your-postgres-container>
# 2. Copy the data directory into a named volume. Give each instance its own
# volume name if this Docker host runs more than one Heym deployment.
docker volume create heym-postgres-data
docker run --rm \
-v "/path/to/your/data/postgres:/legacy:ro" \
-v heym-postgres-data:/pgdata \
--entrypoint sh <your-postgres-image> -c 'cp -a /legacy/. /pgdata/'
# 3. Start PostgreSQL again on the volume, reusing the flags you had before
# but replacing the bind mount.
docker run -d --name <your-postgres-container> --restart always \
... your original -e / -p / --network flags ... \
-v heym-postgres-data:/var/lib/postgresql/data \
<your-postgres-image>
# 4. Once it accepts connections, rebuild indexes.
docker exec <your-postgres-container> reindexdb -U <user> -d <database>Use your own image in steps 2 and 3, not postgres:16. If your database runs pgvector/pgvector:pg16 or another variant, starting the copied cluster with the stock image can fail because the extension libraries it expects are missing.
Your original data directory is untouched by this procedure, so it remains your rollback path. Delete it only after confirming the migrated database serves your workflows. Step 4 matters for the same reason it does in the Compose migration: the copy carries any existing index damage with it.
Running More Than One Instance
Everything above deploys a single Heym instance. When one machine is no longer enough, point a second instance at the same PostgreSQL database and it joins as a worker: background runs — cron, webhooks, MCP tool calls, chat triggers — are then shared between the instances by a percentage you set under Settings → Instances.
The instances never talk to each other. Postgres carries the work, so a worker needs no open port and no route back to the main instance, and no message broker is involved. Work that touches local disk — Drive files, coding-agent workspaces, installed plugins — always runs on the main instance, so nothing has to be shared over a filesystem.
Two rules the cluster cannot enforce for you: every instance must use the same
SECRET_KEY and ENCRYPTION_KEY, and ingress must point at the main instance
only. Both are explained, along with the placement rules and how to choose the
percentages, in Load Distribution.
This is load distribution, not high availability. The main instance remains a single point of failure for ingress, file storage and the editor.
Common Workflows
First-time setup:
./run.sh # development — creates .env from .env.example and generates SECRET_KEY/ENCRYPTION_KEY automatically
# or
./deploy.sh # production — same automatic .env and key generationFor the prebuilt docker run image, generate the keys manually first (see the Prebuilt Image section above).
Update production after a code change:
git pull
./deploy.sh # rebuilds images, zero-downtime swapCheck production logs:
./deploy.sh --logsStop production services:
./deploy.sh --downRelated
- Introduction – Platform overview
- Quick Start – Build your first workflow
- Environment Variables – Full configuration reference
- Security – JWT, encryption, and CORS settings
- Load Distribution – Run several instances against one database