This guide is for the people who run, extend, and reason about Puter at depth: operators self-hosting the platform, architects evaluating it, and developers contributing to or building extensions for the backend. It documents the system's architecture, its configuration surface, deployment topology, security model, operational procedures, and internals — at the level of detail needed to deploy Puter to production, extend it safely, or reimplement it.
Puter is an open-source internet operating system: a browser-based desktop environment backed by a Node.js/Express server, with a client SDK (puter.js), a CLI, and a serverless Workers runtime. For end-user and developer-facing usage (the SDK, the desktop, building apps), see the companion User Guide. This guide covers the parts users don't touch.
Note
All facts here are derived from the source at commit
98a21c4. Where the code and a README disagree, the code wins. The backend is licensed AGPL-3.0-only; the@heyputer/clipackage is MIT.
1. Executive Summary
| Attribute | Value |
|---|---|
| What it is | Self-hostable browser desktop OS + cloud SDK platform |
| Backend runtime | Node.js ≥ 24, TypeScript, Express 5 |
| Repository shape | npm-workspaces monorepo (src/*, tools/*, extensions) |
| Core packages | backend, gui, puter-js, cli, worker, worker-types, mcp-connector, docs |
| Datastores | SQL (SQLite / MySQL·MariaDB / PostgreSQL), Redis-compatible (Valkey), DynamoDB-compatible, S3-compatible object storage |
| Architecture | Layered dependency-injection kernel: clients → stores → services → controllers → drivers |
| Extensibility | Dynamically-loaded extensions register into every layer + add routes |
| AI providers | OpenAI, Anthropic, Google Gemini, Mistral, Groq, Together, Replicate, DeepSeek, xAI, and more, behind a driver abstraction |
| Deployment | Docker Compose (one-line installer) or from source |
| License | AGPL-3.0-only (CLI: MIT) |
Architectural thesis. Puter is a kernel that boots a set of typed registries in dependency order, wires an Express middleware pipeline, and materializes HTTP routes from controllers and extensions. Capabilities that vary by provider (AI, KV, hosting, notifications) are drivers behind named interfaces, callable over a uniform /drivers/call RPC surface and gated by per-method rate-limit and concurrency policies. The whole thing is configured by a single deep-merged JSON config and is designed to run from a laptop (SQLite, in-process fakes) up to a multi-node cluster (MySQL, Redis, S3).
2. System Architecture
2.1 The layered kernel
The backend is organized as five DI layers, instantiated in strict order by PuterServer (src/backend/server.ts). Each layer receives the layers below it as constructor arguments:
┌─────────────────────────────────────────────┐
Request → │ Express middleware pipeline (helmet, CORS, │
│ auth probe, host validation, body parsing…) │
└───────────────┬─────────────────────────────┘
▼
┌──────────────┐ HTTP routes materialized from:
│ CONTROLLERS │ ← controllers (@Controller + registerRoutes)
│ │ ← extensions (extension.get/post/use…)
└──────┬───────┘
│ call into
┌──────▼───────┐ ┌──────────────┐
│ DRIVERS │ │ SERVICES │ business logic, lifecycle
│ (RPC, by │ │ │
│ interface) │ └──────┬───────┘
└──────┬───────┘ │
│ ┌─────▼────────┐
└──────────► │ STORES │ persistence abstractions
└─────┬────────┘
┌─────▼────────┐
│ CLIENTS │ raw backends: db, redis,
│ │ s3, dynamo, email, event
└──────────────┘
| Layer | Constructor receives | Responsibility | Examples |
|---|---|---|---|
| clients | config |
Raw connections to external systems | database, redis, s3, dynamodb, email, event, alarm |
| stores | config, clients, stores |
Persistence/domain data access | app, permission, user, session, fs, kv, subdomain, group, oidc, share, systemKv |
| services | config, clients, stores, services |
Business logic, lifecycle hooks | auth, permission, metering, fs, apps, notification, socket, acl, homepage, selfhosted, health, broadcast, subdomain |
| controllers | config, clients, stores, services, drivers |
HTTP route handlers | auth, fs, apps, drivers, desktop, share, webdav, peer, hosting, oidc, system, notification, static, broadcast, wisp, puterai |
| drivers | config, clients, stores, services |
Pluggable, RPC-callable capabilities | aiChat, aiImage, aiTts, aiVideo, aiOcr, aiSpeech2Txt, aiSpeech2Speech, kvStore, apps, subdomains, notifications, workers |
The registries are plain objects (puterClients, puterStores, etc.). At boot the server iterates each, instantiates classes with the right dependencies, and stashes the live instances in container objects (clientsContainers, storesContainers, …) that the extension system and cross-layer lookups read from.
Note
Drivers are instantiated before controllers so a controller can receive a typed
driversreference. The/drivers/*HTTP surface lives on a regularDriverController.
2.2 Boot sequence
src/backend/index.ts is the entry point. On npm start (after build:ts), it runs the compiled dist/src/backend/index.js:
- Load config —
loadConfig()resolves and deep-merges configuration (see Configuration). - Construct
PuterServerwith the config and all five registries. server.start()— internally#setupServer(): a. Dynamically import extensions fromconfig.extensionsdirectories. b. Instantiate clients, then stores, then services (each merging in extension-registered entries). c. Configure the rate limiter against its backend. d. Wire anti-CSRF and captcha to Redis. e. Build the Express app, settrust proxy, install global middleware. f. Instantiate drivers, then controllers; materialize controller + extension routes. g. Install terminal middleware (404 + error handler).- Signal handling —
SIGINT/SIGTERMtriggerprepareShutdown()thenshutdown()(graceful, with a drain window in multi-node mode).
2.3 HTTP request pipeline
Global middleware is installed in this exact order (#installGlobalMiddleware):
cookieParser
→ compression
→ helmet (noSniff, hsts, ieNoOpen, permittedCrossDomainPolicies, xssFilter; x-powered-by disabled)
→ Cross-Origin-Resource-Policy: cross-origin
→ query-param sanitization (drop non-primitive query values)
→ User-Agent parsing (req.ua)
→ host header validation
→ host redirects (www → root, user-subdomain → static hosting)
→ native-app static serving (editor.*, docs.*, …)
→ CORS (reflect Origin; credentials only on the `api` subdomain)
→ IP validation (optional; emits ip.validate event)
→ OPTIONS preflight (200)
→ body parsing: express.json 50mb (+ a `text/plain;actually=json` shim) + urlencoded 100kb
→ auth probe (populate req.actor from token/cookie; never rejects)
→ per-request ALS context (snapshot actor/requestId)
→ puter-site middleware (*.puter.site / *.puter.app hosting short-circuit)
→ extension global middleware
→ [controller + extension routes]
→ 404 handler
→ error handler (alarms on 5xx)
Per-route options are translated to middleware in #materializeRoute: subdomain gating, requireAuth, requireUserActor, adminOnly, allowedAppIds, requireVerified, a default-on email-confirmation gate (allowUnconfirmed opts out), a default-on non-access-token gate (allowAccessToken opts out), plus captcha, anti-CSRF, rate-limit, and concurrency gates as configured.
Important
Routes without a
subdomainoption are restricted to the root origin — they won't accidentally match API-subdomain requests.subdomain: '*'disables the gate; an explicit subdomain restricts to it. This is how the API surface (api.subdomain) is isolated from the GUI surface.
2.4 The driver model
Drivers are the platform's plugin point for capabilities that have multiple implementations. A driver implements a named interface (e.g. puter-chat-completion); multiple drivers can implement the same interface (OpenAI and Claude both implement chat completion).
Declared two ways — decorator or imperative fields:
// Decorator form
@Driver('puter-chat-completion', { name: 'openai-completion', default: true })
class OpenAIChatDriver extends PuterDriver {
async complete(args) { /* ... */ }
}
// Imperative form (no decorator)
class OpenAIChatDriver extends PuterDriver {
readonly driverInterface = 'puter-chat-completion';
readonly driverName = 'openai';
readonly isDefault = true;
}
Each driver may declare rate-limit and concurrency policies, per-method, with per-subscription overrides:
@Driver('puter-chat-completion', {
concurrent: {
default: { limit: 5, backend: 'redis' },
methods: {
complete: {
limit: 5,
bySubscription: { user_free: 1, unlimited: 50 },
backend: 'redis',
},
},
},
})
- Backends for counting:
memory(per-process),redis(cross-node),kv(DynamoDB-backed). bySubscriptionscales the cap by the caller's subscription tier (user_free,temp_free,unlimited, …) resolved viaMeteringService.- Policies are validated eagerly at decoration/registration time — a malformed block fails the boot, not the first request.
- Methods returning a stream wrap it in a
DriverStreamResult({ dataType: 'stream', content_type, stream }); the/drivers/callhandler pipes it to the response instead ofres.json().
The built-in driver registry (src/backend/drivers/index.ts):
| Registry key | Interface | Implementation |
|---|---|---|
kvStore |
puter-kvstore |
KVStoreDriver |
aiChat |
puter-chat-completion |
ChatCompletionDriver |
aiImage |
puter-image-generation |
ImageGenerationDriver |
aiTts |
puter-tts |
TTSDriver |
aiVideo |
puter-video-generation |
VideoGenerationDriver |
aiSpeech2Speech |
puter-speech2speech |
VoiceChangerDriver |
aiSpeech2Txt / aiSpeech2TxtXai |
puter-speech2txt |
SpeechToTextDriver / XAISpeechToTextDriver |
aiOcr |
puter-ocr |
OCRDriver |
apps |
puter-apps |
AppDriver |
subdomains |
puter-subdomains |
SubdomainDriver |
notifications |
puter-notification |
NotificationDriver |
workers |
puter-workers |
WorkerDriver |
Note
All AI drivers share a single
AI_RATE_LIMIT/AI_CONCURRENTpolicy reference (drivers/util/aiLimits.ts). Because sibling drivers on the same interface (e.g. the two speech-to-text impls) share the same policy object, switching providers mid-session cannot dodge the cap — the controller keys limits by(interface, method, user).
2.5 AI provider topology
The aiChat driver fronts many upstream providers (under drivers/ai-chat/providers/): OpenAI, Anthropic (Claude), Google Gemini, Mistral, Groq, Together, OpenRouter, DeepSeek, xAI, Z.AI, MiniMax, Moonshot, Ollama, and others. Image/TTS/video/OCR/speech each have their own provider sets (e.g. TTS: ElevenLabs, OpenAI, AWS Polly, Gemini, xAI; OCR via AWS Textract; image via OpenAI, Together, Replicate, Cloudflare, Gemini, xAI). The SDK's puter.ai.chat({ model }) selects the model; the driver routes to the right provider. Costs are reported per call and metered against the caller's account by MeteringService.
2.6 Frontend (GUI)
The desktop (src/gui) is a single-page web application built with a custom Node build (build.js/utils.js): it concatenates and minifies a set of jQuery-based libraries, inlines icons as base64, bundles core modules with webpack, and emits dist/gui.js + bundle.min.css. It is served as static assets by the backend (config.gui_assets_root). The desktop sets window.puter_gui_enabled = true, which puter.js detects to run in gui mode and use the desktop's auth token directly. The GUI's own services (src/gui/src/services) extend an AdvancedBase/Service framework (e.g. ProcessService, IPCService, ThemeService, LocaleService, ExecService, AntiCSRFService).
2.7 The client SDK (puter.js)
The Puter class (src/puter-js/src/index.js) is the public client. It detects its environment, registers submodules (auth, os, fs, ui, hosting, apps, ai, kv, perms, drivers, debug, peer, plus workers and net), and manages auth tokens (with a v1→v2 silent-migration path and a reauth coordinator that drives the sign-in popup on 401 { code: 'reauth_required' }). It talks to api.puter.com (or your configured API origin). Full SDK usage is in the User Guide.
3. Configuration
3.1 Config resolution order
loadConfig() (src/backend/index.ts) deep-merges three sources, later sources overriding earlier ones:
config.default.json— bundled OSS defaults (always loaded as the base).- Runtime override —
process.env.PUTER_CONFIG_PATH(absolute path; used in prod/Docker) if set and present; otherwise<package-root>/config.json(gitignored user override); otherwise none. - Computed defaults —
pub_portdefaults toport;originandapi_base_urlare derived fromprotocol/domain/pub_portif not set.
Because the merge is deep, a config.json can override just the keys it cares about (e.g. database) and inherit everything else.
Important
Configuration is a JSON file, not environment variables. The only environment variables the backend itself reads are
PUTER_CONFIG_PATH(config location) and a handful of telemetry vars (OTEL_*,NODE_ENV). Provider keys and operational settings live in the config JSON. Don't expectPUTER_*env vars to configure the running server — that's the installer's job.
3.2 Core configuration keys
From config.default.json (the dev defaults) and the production config.json the installer writes:
| Key | Default (dev) | Purpose |
|---|---|---|
env |
dev |
dev or prod |
port |
4100 |
Bind port |
pub_port |
= port |
Externally-visible port (behind a proxy) |
protocol |
http |
http / https |
domain |
puter.localhost |
Primary domain |
origin |
computed | Externally-visible base URL |
api_base_url |
computed | https://api.<domain> |
cookie_name |
puter_auth_token |
Session cookie name |
static_hosting_domain |
site.puter.localhost |
*.puter.site-equivalent for user sites |
static_hosting_domain_alt |
host.puter.localhost |
Alternate hosting domain |
private_app_hosting_domain |
app.puter.localhost |
Private app hosting |
private_app_hosting_domain_alt |
dev.puter.localhost |
Alternate private app domain |
default_user_group / default_temp_group |
UUIDs | Group assignment for new/temp users |
storage_capacity |
104857600 (100 MB) |
Per-user storage quota (bytes) |
is_storage_limited |
false |
Enforce the quota |
min_pass_length |
6 |
Minimum password length |
strict_email_verification_required |
false |
Require verified email |
enable_public_folders |
true |
Enable ~/Public |
trust_proxy |
unset (false) |
Reverse-proxy hop count (see Security) |
gui_assets_root |
./src/gui |
Where GUI static assets live |
puterjs_root |
./src/puter-js/dist |
Where the built SDK lives |
extensions |
["./extensions"] |
Extension directories to load |
builtin_apps |
{ dev-center: ./src/dev-center } |
Built-in apps served by the server |
3.3 Secrets
| Key | Purpose |
|---|---|
jwt_secret |
Verify-only secret for legacy v1 tokens still in circulation |
jwt_secret_v2 |
Signs every new token |
allow_v1_tokens |
Whether v1 tokens are still accepted |
url_signature_secret |
Signs URLs (e.g. file read URLs, download links) |
Caution
The bundled
config.default.jsonships placeholder secrets (dev-jwt-secret-change-me, etc.) for local development only. The installer generates fresh random secrets (openssl rand -hex 64) for production. Never run a public instance with the default secrets — anyone can forge tokens.
The token scheme is dual-secret: jwt_secret_v2 signs new tokens; jwt_secret only verifies old v1 tokens during the migration window. The SDK silently migrates v1→v2 via /auth/migrate-token; once a v2 token exists the v1 key is cleared.
3.4 Datastore configuration
"database": {
"engine": "mysql", // or "sqlite" / "postgres"
"host": "mariadb",
"port": 3306,
"user": "puter",
"password": "...",
"database": "puter",
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/mysql"]
},
"redis": { "startupNodes": [{ "host": "valkey", "port": 6379 }], "tls": false },
"dynamo": { "endpoint": "http://dynamo:8000", "bootstrapTables": true, "aws": { ... } },
"s3": { "s3Config": { "endpoint": "http://s3:9000", "accessKeyId": "...", "secretAccessKey": "...", "region": "us-east-1", "forcePathStyle": true } },
"s3_bucket": "puter-local",
"s3_region": "us-east-1"
- SQL engines:
sqlite(default dev; zero-config, file atvolatile/runtime/puter-database.sqlite),mysql/MariaDB, andpostgres. Migrations live undersrc/backend/clients/database/migrations/{sqlite,mysql,postgres}— pointmigrationPathsat the matching dialect. - Redis/Valkey: used for sessions, anti-CSRF tokens, captcha, rate limiting, and cross-node socket fan-out. In dev with no nodes configured, an in-process
ioredis-mockis used. - DynamoDB-compatible: the
systemKvstore andkvrate-limit backend.bootstrapTables: trueauto-creates tables. - S3-compatible: object storage for user files.
forcePathStyle: truefor self-hosted MinIO/RustFS endpoints.
3.5 Other notable keys
| Key | Purpose |
|---|---|
rate_limit.backend |
Default rate-limit backend (redis default, or memory/kv) |
providers.ollama.enabled |
Toggle local-LLM provider |
allow_all_host_values / allow_no_host_header |
Relax host validation (dev only) |
captcha.enabled |
Enable the captcha gate |
enable_ip_validation |
Enable the IP-validation gate |
custom_domains_enabled |
Allow custom domains for hosting |
4. Deployment
4.1 Docker Compose (recommended)
The supported deployment is Docker Compose via the one-line installer:
curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
# Windows: irm https://raw.githubusercontent.com/HeyPuter/puter/main/install.ps1 | iex
The installer creates ./puter-selfhosted/, downloads docker-compose.yml and nginx/nginx.conf, generates secrets, writes .env + puter/config/config.json, and runs docker compose up -d. The stack:
| Service | Role |
|---|---|
puter |
The app server (this repo's backend) |
mariadb |
SQL database (MySQL engine) |
valkey |
Redis-compatible cache/coordination |
dynamo |
DynamoDB-compatible KV |
s3 (RustFS/MinIO-class) |
Object storage |
nginx |
Reverse proxy / TLS termination front door |
.env carries the infra secrets (MARIADB_*, S3_*, HTTP_PORT); puter/config/config.json carries the app config (domains, JWT secrets, datastore endpoints, trust_proxy: 1).
Warning
On native Linux (not Docker Desktop), bind-mounted data directories can hit permission errors: several upstream images run as non-root (RustFS UID 10001, dynamo UID 1000) and can't
chownan existing bind-mount. The installerchmod 0777sputer/data/{valkey,mariadb,dynamo,s3,puter}to sidestep the UID mismatch. If you create the stack by hand, replicate this.
4.2 From source (development)
git clone https://github.com/HeyPuter/puter
cd puter
npm install # postinstall sets up extensions
npm start # runs build:ts then the compiled server
Requires Node.js ≥ 24. Default config serves on port 4100 at puter.localhost using SQLite and an in-process fake S3 (fauxqs) — no external services. Build scripts:
| Script | Effect |
|---|---|
npm start / npm run dev |
build:ts then run dist/src/backend/index.js |
npm run build |
Build backend TS + GUI + puter-js |
npm run build:ts |
tsc -p tsconfig.build.json + write dist package.json |
npm run build:workerLib |
Build puter-js then the worker preamble |
npm run test:backend |
Vitest backend suite (:postgres variant for PG) |
npm run check-translations |
Validate i18n completeness |
4.3 Reverse proxy and trust_proxy
Puter is designed to run behind a reverse proxy (nginx in the stack, or Cloudflare). The pub_port/origin split exists precisely so the externally-visible URL can differ from the bind port. Set config.trust_proxy to the number of proxy hops (e.g. 1 for a single nginx/Cloudflare hop) so Express derives req.ip/req.protocol from X-Forwarded-For correctly.
Caution
Never set
trust proxytotruein production. That trusts every hop and makesX-Forwarded-Forforgeable — a client could spoof its IP and defeat IP-based gating and rate limiting. Always use the exact hop count.
4.4 Multi-node scaling
Puter scales horizontally because its coordination state lives in shared backends, not process memory:
- Sessions, anti-CSRF, captcha → Redis (issue on node A, consume on node B).
- Rate limiting → Redis sorted-sets by default (cross-node), or
kv(DynamoDB). - Socket.IO fan-out →
@socket.io/redis-streams-adapterbroadcasts events across nodes. - Object storage / DB → shared S3 + SQL.
Set rate_limit.backend: redis and use memory backends only on single-node deployments. The graceful-shutdown drain window lengthens when a serverId is configured (multi-node), giving in-flight requests time to finish.
4.5 DNS / hosting domains
A production instance needs DNS for the primary domain and the hosting wildcards: the main domain, static_hosting_domain (and _alt) for *.puter.site-style user sites, and the private_app_hosting_domain (and _alt) for app hosting. Point wildcard A records at the instance; nginx routes by Host.
5. Security
Puter's security model is built into the request pipeline and the actor/permission system.
5.1 Authentication
- Tokens: JWTs signed by
jwt_secret_v2. Legacy v1 tokens are verify-only viajwt_secretduring migration;allow_v1_tokensgates acceptance. The SDK migrates v1→v2 silently and falls back to an interactive reauth flow on401 { code: 'reauth_required' }. - Cookie: session token in
config.cookie_name(puter_auth_token). - Auth probe: a global middleware that populates
req.actorif a valid token/cookie is present but never rejects — rejection is the job of per-route gates. - Actors and subscriptions: every authenticated request carries an actor with a subscription tier (
user_free,temp_free,unlimited, …) used by metering and per-tier rate/concurrency caps. ASYSTEM_ACTORbypasses metering for internal calls.
5.2 Route-level gates
Materialized per route from options (#materializeRoute):
| Gate | Effect |
|---|---|
requireAuth |
Reject unauthenticated requests |
requireUserActor |
Require a real user (not an app/access token) |
adminOnly |
Admin accounts only (implies requireAuth) |
allowedAppIds |
Restrict to specific app IDs (implies requireAuth) |
requireVerified |
Require a verified account |
| email-confirmation gate | Default-on for authed routes; allowUnconfirmed opts out |
| non-access-token gate | Default-on; allowAccessToken opts out |
subdomain |
Restrict the route to a subdomain (root-only by default) |
| captcha / anti-CSRF | Bot / CSRF protection where configured |
| rate-limit / concurrency | Throttle by (interface/route, method, actor) |
Important
Authenticated routes reject unconfirmed accounts by default — unconfirmed users can't touch AI, FS, or driver endpoints, only essential flows (logout, confirm-email, whoami, save-account). Access tokens are also blocked by default. Both are deliberate opt-out gates, not opt-in.
5.3 Transport and headers
The global pipeline applies Helmet (noSniff, hsts, ieNoOpen, permittedCrossDomainPolicies, xssFilter), disables x-powered-by, sets Cross-Origin-Resource-Policy: cross-origin, and sanitizes query parameters to primitives. CORS reflects the caller's Origin (puter.js is meant to be embedded on arbitrary sites) but only sets Access-Control-Allow-Credentials: true on the api subdomain — never on the main GUI domain, which also gets X-Frame-Options: SAMEORIGIN to block framing.
5.4 Host validation and IP gating
Host headers are validated against the configured domain set (main + hosting domains, plus at.<static> and optional nip.io); unknown hosts get 400 unless custom_domains_enabled. /healthcheck is always reachable regardless of host. With enable_ip_validation, an ip.validate event lets listeners (IP-reputation/Redis checks) allow or deny per request — using req.ip (which honors trust proxy), never raw X-Forwarded-For.
5.5 Rate limiting and abuse control
Per-driver/method rate-limit and concurrency policies (with per-subscription tiers) throttle expensive operations. Examples from the source:
| Driver | Default rate limit | Notes |
|---|---|---|
| KV | 400 / 10s (temp users 200) | No concurrency cap (cheap) |
| Apps | 100 / 10s (temp 50) | |
| Subdomains | 200 / 10s (temp 100) | |
| Notifications | 3000 / 30s (temp 1000) | Poll-heavy, generous |
| AI (all) | Shared AI_RATE_LIMIT / AI_CONCURRENT |
Per-subscription concurrency on complete |
Counting backends: redis (cross-node, default), memory (single-node), kv (DynamoDB). Policies validate at boot, so a typo can't silently disable a limit.
5.6 Operational alerting
The terminal error handler raises an alarm (clients.alarm) on 5xx errors (and on forced codes like upstream_rate_limited/upstream_auth_failed), de-duplicated by route + error signature so a crash loop pages once with N occurrences rather than N pages. Errors prefixed upstream_/client_ are surfaced to the user but don't page.
5.7 Worker and content isolation
User-hosted sites (*.puter.site/*.puter.app) are short-circuited by a dedicated middleware before any API/GUI route. Workers run in a sandboxed Worker runtime; a request carrying a puter-auth header gets a scoped user.puter context, while the deployer's own resources are reachable via me/my/myself. CORS on workers defaults to permissive but is toggleable per worker.
Caution
Because
puter.jsis designed to be embedded on third-party sites and the user pays for usage, the platform's trust boundary is the user's authorization, not the embedding origin. The consent popup + per-app sandbox (~/AppData/{app-id}) + explicitputer.permsrequests are what protect a user's broader data from an app they authorized. Operators enabling new drivers should set appropriate rate/concurrency caps to bound abuse.
6. Extensions
Puter supports dynamically-loaded backend extensions (configured via config.extensions directories, default ./extensions). An extension is a module that, at load time, writes into a shared extensionStore through a global extension API. PuterServer drains that store during boot, merging extension entries into each layer.
The extension API surface (src/backend/extensions.ts):
| Capability | API |
|---|---|
| Register into layers | registerClient, registerStore, registerService, registerController, registerDriver |
| Global middleware | registerGlobalMiddleware(mw) |
| Routes | extension.get/post/put/delete/patch/head/options/all(path, options?, handler) and extension.use(...) |
| Events | extension.on(eventKey, (key, data, meta) => …) |
| Config access | extension.config (lazy proxy, populated at boot) |
| Cross-layer lookup | `extension.import('service:foo' |
Extension routes use the same RouteOptions vocabulary as controllers (subdomain, requireAuth, requireUserActor, adminOnly, allowedAppIds, body parsers), so they get identical gate and parser treatment — there is no second-class route path. Extension-registered clients/stores/services/controllers/drivers are instantiated alongside the built-ins.
// An extension module (loaded from an extensions directory)
extension.get('/hello', { requireAuth: true }, (req, res) => {
res.json({ hello: req.actor.username });
});
extension.registerDriver('myThing', MyThingDriver);
extension.on('user.created', async (key, data, meta) => {
// react to platform events
});
Tip
Extensions are the supported way to add custom backend behavior without forking the core. TypeScript declaration merging (
IExtensionDriverInstances, etc.) flows extension types intothis.driversand theextension.import('driver')proxy so custom layers are typed.
7. Operations
7.1 Health checks
GET /healthcheck is reachable regardless of Host (it bypasses host validation) — point your load balancer / orchestrator liveness probe at it. A health service and HealthController provide deeper status.
7.2 Logs and the admin password
cd puter-selfhosted
docker compose logs -f puter # follow app logs
docker compose logs puter | grep password # first-boot admin credentials (printed once)
7.3 Start / stop / update
docker compose up -d # start
docker compose down # stop
docker compose pull && docker compose up -d # update images
For a from-source deployment, SIGTERM/SIGINT trigger graceful shutdown; prepareShutdown() runs first, then shutdown() after a drain window (longer when serverId indicates a multi-node deployment).
7.4 Backups
Back up three things: the SQL database (mysqldump / sqlite file / pg_dump), the S3 object store (user files), and the config + secrets (puter/config/config.json, .env). The DynamoDB-compatible store holds rebuildable system KV; Redis/Valkey is cache and can be lost. Losing jwt_secret_v2/url_signature_secret invalidates all sessions and signed URLs — treat them as the crown jewels.
7.5 Telemetry
The backend integrates OpenTelemetry (src/backend/telemetry.ts), configured by env: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_TRACE_SAMPLE_RATIO, OTEL_SERVICE_NAME. npm start preloads the telemetry module (-r ./dist/src/backend/telemetry.js).
7.6 Monitoring abuse
Watch for: alarm pages on upstream_rate_limited/upstream_auth_failed (provider problems), 5xx alarm clusters (de-duped by route+signature), and rate-limit rejections. Tune driver caps and per-subscription tiers if a tier is being abused.
8. Repository Structure
puter/ (npm workspaces: src/*, tools/*)
├─ config.default.json OSS dev defaults (port 4100, sqlite, placeholder secrets)
├─ install.sh / install.ps1 one-line self-host installers
├─ src/
│ ├─ backend/ @heyputer/backend — the kernel (Express 5, TS)
│ │ ├─ index.ts entry: loadConfig + start PuterServer
│ │ ├─ server.ts PuterServer: layer wiring + middleware pipeline
│ │ ├─ extensions.ts extension API + extensionStore
│ │ ├─ clients/ db (sqlite/mysql/postgres), redis, s3, dynamodb, email, event, alarm
│ │ ├─ stores/ app, permission, user, session, fs, kv, subdomain, …
│ │ ├─ services/ auth, permission, metering, fs, apps, notification, socket, …
│ │ ├─ controllers/ auth, fs, apps, drivers, desktop, webdav, hosting, oidc, …
│ │ ├─ drivers/ ai-chat/-image/-tts/-video/-ocr/-speech*, kv, apps, subdomain, workers
│ │ └─ core/http/middleware/ gates, authProbe, rateLimit, captcha, antiCsrf, hostRedirects, …
│ ├─ gui/ @heyputer/gui — the desktop SPA (jQuery + webpack build)
│ ├─ puter-js/ @heyputer/puter.js — the client SDK
│ ├─ cli/ @heyputer/cli — the `puter` command (MIT)
│ ├─ worker/ @heyputer/worker — worker preamble + s2w router
│ ├─ worker-types/ @heyputer/worker-types — Workers runtime TS types
│ ├─ mcp-connector/ Cloudflare-Worker MCP server for Puter (+ OAuth)
│ ├─ dev-center/ built-in app: developer center
│ └─ docs/ docs.puter.com site generator (sidebar = API reference)
├─ extensions/ default extensions directory (loaded at boot)
├─ nginx/ reverse-proxy config (fetched by installer)
└─ tools/ build/maintenance scripts (translations, dist packaging)
Note
The MCP connector (
src/mcp-connector) is a Cloudflare-Worker MCP server that exposes Puter filesystem/subdomain operations to AI agents, authenticated per-caller with their own Puter token and an OAuth flow (.well-known/oauth-*,/authorize,/token,/register). It's an optional integration, not part of the core server.
9. Code Quality & Conventions
- TypeScript-first backend with strict ESLint + Prettier (4-space indent, single quotes). The build uses a relaxed
tsconfig.build.json(noCheck, non-strict) for emit speed while type-checking happens separately. - Dependency injection by convention: every layer class takes
(config, [clients], [stores], [services], [drivers])positionally. This makes the wiring inserver.tsuniform and testable (tests pass empty mocks). - Eager validation: driver policies validate at decoration/registration, config resolves at boot — misconfiguration fails fast, not at first request.
- Fail-loud errors with structured codes:
HttpErrorwithlegacyCode/statusCode; the error handler distinguishes 4xx (client) from 5xx (page-worthy), withupstream_/client_prefixes controlling alerting. - Test stack: Vitest (backend, with a Postgres variant), Mocha (GUI), Playwright (puter-js e2e). e2e tests read the admin password from
PUTER_ADMIN_PASSWORD(the first-boot banner). - Extension parity: extension routes and layers are first-class — same options, same materializer — so the plugin path doesn't rot relative to core.
10. Potential Improvements
These are observations from the source, useful for contributors and operators planning upgrades:
- Config is JSON-only. There's no env-var override for individual settings beyond
PUTER_CONFIG_PATH. Operators who prefer 12-factor env config must template the JSON themselves (as the installer does). A documented env→config mapping would help container deployments. - Default secrets are footguns.
config.default.jsonships working placeholder JWT/URL secrets. A boot-time refusal to start inenv: prodwith a default secret would harden against accidental exposure. trust_proxycorrectness is load-bearing. IP gating and rate limiting depend on the correct hop count; a misconfigured proxy silently degrades both. A startup self-check (comparereq.ipagainst a known probe) could warn operators.- Podman support is unofficial. The installer and compose target Docker; community demand exists (#3170). Testing/documenting a Podman path would broaden self-host reach.
- Worker readiness signal. The CLI cannot confirm a worker deploy is live (no liveness signal yet) — a readiness endpoint or status field would let
puter worker deployverify success. - Native-Linux bind-mount permissions. The
chmod 0777workaround for non-root container UIDs is pragmatic but broad; per-service UID mapping would be tighter.
11. Quick Reference
Ports & domains (defaults)
| Item | Dev default |
|---|---|
| Backend bind port | 4100 |
| GUI dev server | 4000 (auto-increments if busy) |
| Primary domain | puter.localhost |
| Static hosting | site.puter.localhost / host.puter.localhost |
| App hosting | app.puter.localhost / dev.puter.localhost |
| Health check | GET /healthcheck |
Layer instantiation order
clients → stores → services → drivers → controllers (extensions merged into each).
Key files
| File | Role |
|---|---|
src/backend/index.ts |
Entry point + config resolution |
src/backend/server.ts |
PuterServer kernel: wiring + middleware + routes |
src/backend/extensions.ts |
Extension API |
src/backend/drivers/meta.ts |
Driver metadata + rate-limit/concurrency policy |
src/backend/drivers/index.ts |
Built-in driver registry |
config.default.json |
Bundled dev defaults |
install.sh / install.ps1 |
Self-host installers |
Driver interfaces
puter-chat-completion · puter-image-generation · puter-tts · puter-video-generation · puter-speech2speech · puter-speech2txt · puter-ocr · puter-kvstore · puter-apps · puter-subdomains · puter-notification · puter-workers
Subscription tiers
user_free · temp_free · unlimited (extensible; resolved via MeteringService)
Operational commands
docker compose up -d # start the stack
docker compose logs -f puter # follow logs
docker compose logs puter | grep password # first-boot admin password
docker compose pull && docker compose up -d # update
curl -fsSL .../install.sh | sh # (re)install — keeps existing secrets