Self-Deployment Guide
Self-host UnDercontrol with a single Docker command. The all-in-one image bundles the frontend and backend in one container, so there is nothing to wire together — just run it and open your browser.
The images are published for both linux/amd64 and linux/arm64 (Apple Silicon, ARM servers), so the same command works on any machine.
Quick Start (Free / Personal)
No license required. Single user, SQLite, local file storage.
docker run -d --name undercontrol \
-p 3000:8080 \
-e HOST_DOMAIN=http://localhost:3000 \
-e JWT_SECRET=change-me-to-a-random-string \
-v undercontrol-data:/app/data \
lintao0o0/undercontrol:latest
Then open http://localhost:3000 and click Start. The frontend and backend are in
the same container and connect automatically via /api/v1 — no server URL to configure.
First-boot banner
The container logs make success and failure obvious. On a successful boot,
docker logs undercontrol ends with a ready banner telling you exactly where to go
and how to log in:
==============================================================================
UnDercontrol v1.x.x is ready
--> Open http://localhost:3000 to get started
Login as: personal@undercontrol.local
default password: personal123 (set PERSONAL_TIER_PASSWORD to change it)
Tier: Personal (max users: 1)
Database: SQLITE
Storage: LocalFS
==============================================================================
If the configuration is broken, the container exits immediately and the same logs show
a STARTUP FAILED block explaining exactly what to fix — a missing HOST_DOMAIN, a
missing ADMIN_EMAIL on Pro/Max, or a port already in use. The password hint only
appears while the account is still on the shipped default password.
Bare-metal (npm, no Docker)
The server is also published as an npm package with the web UI compiled into the binary — nothing else to install. Requires Node.js 18+. Available for macOS (Intel & Apple Silicon), Linux (x64 & ARM64), and Windows (x64).
npm install -g @oatnil/ud-server @oatnil/ud # server + CLI
ud-server -host-domain http://localhost:8080 -data-path ./data
Then open http://localhost:8080 — the same ready banner as Docker prints in the
terminal with the login credentials. Everything lives under ./data (SQLite database
and uploads), so backing up or moving the instance is copying that directory.
- Configuration is identical to Docker: every environment variable in the
Configuration reference also works as a CLI flag
(
ud-server -helplists them).HOST_DOMAINis the only required setting. - Licenses work the same way: export
LICENSE_TOKEN/LICENSE_HOST_SECRETbefore starting to unlock Pro features. - Upgrade with
npm update -g @oatnil/ud-server; uninstall withnpm uninstall -g @oatnil/ud-server(your./datadirectory is untouched). - To run it as a service, wrap the command in systemd / launchd like any other single binary.
Pro / Max (Multi-user)
Add a license token and an admin account to unlock multi-user, PostgreSQL, S3 storage and the admin dashboard. Contact the UnDercontrol team for a license token.
docker run -d --name undercontrol \
-p 3000:8080 \
-e HOST_DOMAIN=http://localhost:3000 \
-e JWT_SECRET=change-me-to-a-random-string \
-e ADMIN_EMAIL=admin@example.com \
-e ADMIN_PASSWORD=your-secure-password \
-e LICENSE_TOKEN=your-license-token \
-e LICENSE_HOST_SECRET=your-license-host-secret \
-v undercontrol-data:/app/data \
lintao0o0/undercontrol:latest
Log in with the ADMIN_EMAIL / ADMIN_PASSWORD you set.
On Pro/Max tier the initial admin user is created from ADMIN_EMAIL at startup. If it is
missing the server refuses to boot with a clear error — set it (and ADMIN_PASSWORD)
before starting.
docker-compose
For persistent data and easier configuration, use docker-compose:
services:
undercontrol:
image: lintao0o0/undercontrol:latest
ports:
- "3000:8080"
volumes:
- ./data:/app/data
environment:
- HOST_DOMAIN=http://localhost:3000
- JWT_SECRET=change-me-to-a-random-string
# Pro/Max only:
# - ADMIN_EMAIL=admin@example.com
# - ADMIN_PASSWORD=your-secure-password
# - LICENSE_TOKEN=your-license-token
# - LICENSE_HOST_SECRET=your-license-host-secret
docker compose up -d
Environment Variables
The table below covers the variables most deployments touch. For every setting the server reads — with an interactive config builder and boot preview — see the Configuration Reference.
| Variable | Required | Default | Description |
|---|---|---|---|
HOST_DOMAIN | Yes | — | Public URL clients use to reach this instance. Used to build file download/upload links, so it must be reachable (e.g. http://localhost:3000 or https://ud.example.com). |
JWT_SECRET | Yes | — | Random secret used to sign auth tokens. |
ADMIN_EMAIL | Pro/Max | — | Login username of the initial admin user. Required on Pro/Max tier. |
ADMIN_PASSWORD | Pro/Max | admin123 | Initial admin password. Change it. |
LICENSE_TOKEN | Pro/Max | — | License token that unlocks Pro/Max features. |
LICENSE_HOST_SECRET | Pro/Max | — | Host secret paired with your license token. |
PERSONAL_TIER_PASSWORD | No | personal123 | Password of the single Personal-tier user (personal@undercontrol.local). Set it before first boot: the Start auto-login always uses this variable, so changing only the env var after the user exists — or changing only the password in-app — breaks auto-login (the two must match; the login name itself cannot be changed). |
PORT | No | 8080 | Port the server listens on inside the container. |
UD_ENCRYPTION_KEY | Messenger | — | Key used to encrypt user-owned secrets at rest — today each user's own messenger bot token. Required before anyone can connect a messenger: without it the Messenger section refuses to store a token and says so. Treat it as permanent per instance — changing it strands every stored token and each user must paste theirs again. |
IM_MAX_BYO_BOTS | No | 20 | How many user-owned messenger bots this instance will run at once. Each holds one long-polling connection. |
Optional: PostgreSQL, S3 and AI
The all-in-one image defaults to SQLite + local file storage, which is enough for most self-hosted instances. On Pro/Max you can point it at external services with additional environment variables:
- PostgreSQL — set
DATABASE_URL(or the individualDB_*variables) to a Postgres connection instead of the bundled SQLite. - S3 / R2 storage — set the
S3_*variables to store uploaded files in S3-compatible object storage (AWS S3, Cloudflare R2, MinIO) instead of the local volume. - AI provider — set the OpenAI-compatible
AI_*variables to enable AI features.
Optional: Telegram messenger for Alfred
Alfred is the built-in butler agent. Users talk to him by mentioning @alfred in any comment
on the web, and that works on every instance with no configuration at all. The messenger
channel adds the phone: each user connects their own Telegram bot and messages Alfred
from anywhere.
There is no instance-wide bot token. Every user creates a bot, holds its credential, and their chats arrive on it and nowhere else — which is the only arrangement that stays honest once more than one person uses an instance.
What the operator does — once:
- Set
UD_ENCRYPTION_KEYto a random secret. Bot tokens are stored AES-256-GCM encrypted and cannot be stored at all without it; the Messenger section tells users to ask you if it is missing. Changing it later strands every stored token, so pick it before your users start. - Optionally raise or lower
IM_MAX_BYO_BOTS(default 20), the number of user bots this instance will run at once. It is also editable at runtime in Admin → System Config → Integration. - On a multi-user instance, decide when to open the bridge beyond yourself — see Multi-user access below.
What each user does — themselves:
- Talk to @BotFather on Telegram, send
/newbot, and take the token it hands back. The bot's name and picture are theirs. - Paste the token in Profile → Messenger. The server checks it with Telegram before storing anything, so a mistyped token is an error next to the field rather than a bot that never answers. Once saved it can never be read back — the page shows the bot's handle and a mask.
- Send
/startto their own bot. Telegram requires it before a bot may message them. - Generate a one-time link code in the same section and send
/link CODEto their bot within 10 minutes.
Each bot is a private entrance: anyone who is not its owner gets a polite refusal, and a link code only works on its own owner's bot. Replacing a token reconnects; removing one stops the connection and deletes the credential, leaving the account binding and the conversation history intact.
A token Telegram later rejects (a reset in @BotFather, say) shows as token rejected in the user's own Messenger section with the messenger's own error, and they fix it by pasting a new one. There is no admin repair path any more, and nothing falls back to another user's bot.
DISCORD_BOT_TOKEN / --discord-bot-token is still accepted by the configuration, but no
Discord provider is implemented yet — setting it alone starts nothing.
Multi-user access to the messenger bridge
The bridge ships locked to the instance owner: im.multi_user_enabled defaults to false, and
while it is off any other user sees multi-user access is not open yet in place of the
Messenger section. Mentioning @alfred on the web is unaffected.
Open it in Admin → System Config once you are satisfied with the boundary — every message runs an agent session on a machine, so this is the switch that decides whose machines a second user's messages can reach.
Upgrading an instance that used the old shared bot
Earlier versions had one operator-configured TELEGRAM_BOT_TOKEN for the whole instance. That
key is gone, and the upgrade needs two manual steps. Read this section before upgrading.
The automatic migration only picks up a token that was stored as the runtime setting
integration.telegram.bot_token. That setting existed only briefly during development and
shipped in no release, so on every real instance the token lives in the
TELEGRAM_BOT_TOKEN environment variable (or the matching flag) — which the migration does
not read. In practice this means: nothing is migrated automatically, and the failure is
silent. The container still boots healthy and the logs still say the bridge started.
Upgrade like this:
- Copy your existing
TELEGRAM_BOT_TOKENvalue somewhere safe before you change anything. It becomes inert after the upgrade, and it is the only copy you have. - Set
UD_ENCRYPTION_KEYto a random secret (openssl rand -hex 32) in the same configuration. Without it the server refuses to store any bot token at all. Treat it as permanent — see the variable's note above. - Upgrade the image and start the instance as usual.
- Re-register the token by hand. The owner opens Profile → Messenger and pastes the token from step 1. The server verifies it with Telegram before storing, so a mistyped token is an error on the spot rather than a bot that never answers.
- Restart the instance once more. This step is easy to miss and matters: conversations and deliveries created by the old shared bot are not attached to any per-user bot yet, and the new runtime can only see rows that are. The restart is what attaches them, and it only works once a bot exists — which is why it comes after step 4.
Until step 5 is done, the bridge looks healthy but replies to existing conversations are
silently dropped. There is no error in the interface; the only symptom is a log line reading
IM relay has no live bot for a conversation, dropping message with botState="unmigrated".
New conversations started after the upgrade are unaffected, which can make the problem look
intermittent.
Verify by sending a real message and confirming a reply comes back — a healthy container, a clean log and a started bridge do not prove delivery works.
Once the messenger is confirmed working you can drop TELEGRAM_BOT_TOKEN from your
configuration. Keeping it until then costs nothing and lets you roll back to the previous
version in place.
Other users are not migrated, because there was nothing of theirs to migrate: they were using the operator's bot. Each connects their own.
First-run onboarding
The first time a user opens a fresh instance they get a five-step wizard. Every step is skippable, and anything already configured shows up as completed instead of asking again. Nothing here is required for the server to run — a bare instance completes onboarding fine.
- Language — English or Chinese for the interface.
- Workspace status hooks — asks permission to add Claude Code hooks to the workspace
project's
.claude/settings.local.json, which is what lets the desktop app show live agent status (running / waiting / idle). Nothing is written unless the user allows it. - Register this machine as a daemon — a daemon is the machine that runs agent sessions.
In the UnDercontrol desktop app this is one click, plus a scan of the agent CLIs installed
locally (Claude Code, Codex, …). In a browser the step recommends the desktop app, or gives
the headless path for a server or remote box:
npm install -g @oatnil/ud,ud login,ud daemon start. Either way the step completes as soon as the server reports an online daemon. - Connect Telegram — the user creates their own bot with @BotFather and pastes its token,
the same flow that lives in Profile → Messenger. Skippable like every other step; the first
instruction points back at step 3, because a bot with no machine behind it answers nothing.
On an instance with no
UD_ENCRYPTION_KEYthe step explains that tokens cannot be stored yet and to ask the administrator. - Meet Alfred — introduces the built-in butler agent (dispatching work to the right agent,
remembering preferences and decisions, filing quick captures) and points users at
@alfredmentions in web comments.
What the operator must do: set UD_ENCRYPTION_KEY before your users go through onboarding
if you want step 4 to work. Nothing else in the wizard depends on server configuration, and a
user who skips step 4 can connect a bot later from Profile → Messenger.
Separate frontend / backend images (advanced)
If you need to scale the frontend and backend independently, or put them behind different proxies, they are also published as separate multi-arch images:
- Backend —
lintao0o0/undercontrol-backend:latest - Frontend —
lintao0o0/undercontrol-vite-app:latest
The backend takes the same environment variables as above; the frontend serves the static
assets via nginx and proxies /api to the backend.
Running more than one backend replica. Correctness no longer depends on a single replica: the cap of one live Alfred session per user is enforced by a database index, so a second replica that races the first loses its insert and hands the message to the session that won. The messenger bridge is still happiest on one replica, though — each replica opens its own long poll per user bot, and Telegram allows one consumer per bot, so several replicas polling the same bots steal each other's messages. Run one backend replica if users connect messengers; scale the frontend freely either way.
Data & Backup
All state lives under /app/data (mounted as a volume above): the SQLite database and, by
default, uploaded files. Back up that volume to back up your instance. If you use external
PostgreSQL and S3, back those up instead.
Troubleshooting
Start with docker logs undercontrol: a healthy boot ends with the ready banner shown
above, and a misconfigured one ends with a STARTUP FAILED block naming the exact
variable to fix.
- Container exits immediately — read the
STARTUP FAILEDblock in the logs. The common causes are a missingHOST_DOMAIN, a missingADMIN_EMAILon Pro/Max, or the port already being in use. no matching manifest for linux/arm64/v8— update to the latest image; it is now published for both amd64 and arm64.- Don't know where to log in — the ready banner prints the URL and the login account for your tier.
- File links are unreachable —
HOST_DOMAINmust be the URL clients actually use to reach the instance, including scheme and port. - If you're still stuck, contact support with the logs and your configuration (remove sensitive data).