Pocker
Pocker hosts many PocketOne instances in one process. It is PocketOne itself (this repo tracks
upstream PocketBase and shares its history) plus a small engine in
this folder, run by the stock pocketone binary as a runtime provider (provider: pocker in pocketone.yaml).
- One port, many instances. Each request's hostname maps to a canonical instance id.
- Each instance is a normal app folder,
<root>/<id>(pb_data,pb_hooks,pb_migrations,pb_public), with its own environment for JS hooks ($os.getenv,process.env), its own JS migrations and its own settings encryption key. - An instance's app starts on its first request (tens of milliseconds) and keeps running.
Dev mode: any hostname
# pocketone.yaml
server: { http: "127.0.0.1:8090" }
provider: pocker
instances: { dev: true }
pocker: { root: ./pb_root }
pocketone serve
curl -H 'Host: blog.localhost' http://127.0.0.1:8090/api/health # creates and serves ./pb_root/blog.localhost
A hostname the instance source doesn't map uses the hostname itself as its id (lowercase, no port, no trailing dot; anything that isn't a safe folder name gets 400), its folder is created on first use, and apps run in dev mode. The instances file (or another source) is optional in dev mode; hosts it maps keep their own ids.
Production: mapped hostnames only
Without dev mode, Pocker serves only what an instance source maps. The built-in source is the instances section's
YAML file (reloaded when it changes; the host refuses to start without it):
# instances.yaml
instances:
- id: blog # folder <root>/blog
hosts: [blog.example.com, www.blog.example.com]
- id: shop
hosts: [shop.example.com]
secrets: { STRIPE_KEY: sk_live_... } # the app's environment
encryption_key: 0123456789abcdef0123456789abcdef # encrypts the app's stored settings; not visible to JS
access: { cors_origins: [https://shop.example.com] }
extensions: { firewall: { trusted_ips: [203.0.113.0/24] } } # settings for extensions, by name
- id: old
hosts: [old.example.com]
blocked: { status: 402, message: "Payment required" }
Unknown hosts get 404 and no folder is ever created from a request. Ids are lowercase letters, digits, dots and dashes.
Hosting platforms plug in their own InstanceSource (for example a control plane streaming the
hostname -> id map over SSE) instead of the file; a community mothership is one (the mothership section,
ext/multisite-mothership).
pocketone.yaml for a production host (each extension owns its section; every field has a default):
server:
https: ":443"
http: ":80" # redirects to HTTPS (and answers ACME HTTP-01 challenges)
plain: [] # extra plain-HTTP listeners (no TLS, no redirect), e.g. a private network other servers forward on
tls: { cert: /path/fullchain.pem, key: /path/privkey.pem } # re-read when they change; or the autotls section
provider: pocker
instances: { file: ./instances.yaml }
pocker:
root: ./pb_root
hooks_pool_size: 2
egress_allow: [] # private networks apps may still reach (default none)
# default limits for every instance (each instance can override them, see below)
max_js_execution: 30s # any single JS call (hook, route, cron, migration)
max_js_allocation: 64MiB # one-shot JS allocations
max_call_stack_size: 2000
max_sleep: 10s
max_query_duration: 30s # any SQL statement without its own deadline
http_send_max_timeout: 60s
http_send_max_body: 10MiB
max_concurrent_js: 16 # JS calls an instance runs at once; more wait up to max_js_execution, then fail
rate_limits: # per client IP and per instance; trusted clients get the trusted set
ip_hourly: 1000
instance_hourly: 10000
ip_concurrent: 15
instance_concurrent: 250
file_weight: 0.1 # a /api/files request costs a tenth
trusted: { ip_hourly: 5000, instance_hourly: 20000, ip_concurrent: 50, instance_concurrent: 250 }
# off: true # no rate limiting (the default in dev mode unless rate_limits is set)
firewall:
allow_cidrs: [] # peers allowed to connect (empty = everyone); others get 403
trusted_proxies: [] # peers whose client-IP headers are believed
client_ip_headers: [CF-Connecting-IP, True-Client-IP, X-Forwarded-For, X-Real-IP]
trusted_clients: [] # client IPs with the trusted limits on every instance
trusted_client_headers: [X-Client-IP] # a trusted client names its end user (own buckets)
rate_limit_header_prefix: X-RateLimit
# autotls: # automatic certificates (ACME, via CertMagic, Caddy's certificate engine)
# auto: true
# email: [email protected]
# ca: letsencrypt # letsencrypt (default), letsencrypt-staging, zerossl, or an ACME directory URL
# storage: ./pb_root/.pocker-certs
# domains: ["*.example.com", "example.com"] # obtained up front; wildcards need dns
# dns: { provider: cloudflare, api_token_env: CF_API_TOKEN } # only needed for wildcards
Firewall (ext/multisite-firewall; on whenever the config has firewall or rate_limits). Peers
outside allow_cidrs get 403. The client IP comes from client_ip_headers only when the peer is a trusted proxy,
otherwise it is the peer. Rate-limited requests get 429 with Retry-After and a message naming the limit; every routed
response carries <prefix>-{Ip,Instance}-{Hourly,Concurrent}-{Limit,Remaining} and <prefix>-{Ip,Instance}-Hourly-Reset,
listed in Access-Control-Expose-Headers. Long-lived responses (realtime) hold their concurrency slot until they end.
TLS. Certificate files are core (server.tls). Automatic certificates are ext/autotls (its
autotls section): with auto: true and no dns, every hostname the instance source maps gets its own certificate on
its first TLS handshake (HTTP-01 / TLS-ALPN-01), with any DNS host and no API token; hostnames no instance serves never
trigger issuance. A DNS provider (Cloudflare for now) is only needed for wildcard domains, proved through DNS-01; a
wildcard without one is a config error.
Each instance can override any of those limits (zero or missing fields keep the default):
instances:
- id: shop
hosts: [shop.example.com]
limits:
max_js_execution: 60s
max_js_allocation: 128MiB
- id: api
hosts: [api.example.com]
access: { cors_origins: [https://app.example.com] }
limits: { max_concurrent_js: 32 }
extensions:
firewall: { rate_limits: { ip_hourly: 5000, instance_concurrent: 500 }, trusted_ips: [203.0.113.0/24] }
Extensions
pocker.Provider.Extensions (or pocker.Server.Extensions in Go) takes extensions (see extension.go). Each implements Name() and
any of:
| Interface | Used for |
|---|---|
extkit.Extension |
a PocketOne extension, registered on every instance's app before it bootstraps (Register(app, cfg)) with the instance's extensions.<name> settings, its id in extkit.AppID(app); e.g. pitr, storage, tokens, s3redirect, mailrelay, bulkmail. enabled: false skips it for that instance. A change to an instance's extensions: settings restarts its app on its next request. The same value works on a single app. |
Configurer |
reading its own pocketone.yaml section before the server starts (Configure(cfg)) |
Gate |
a peer check before routing, and admission (e.g. rate limits) before an instance is served or forwarded |
TLSProvider |
the main listener's TLS (e.g. automatic certificates) |
Wrapper |
wrapping the server's handler (the first listed is outermost) |
Starter |
background work once the Manager exists |
AppIniter |
host code that needs the whole Instance when setting up its app (InitApp(inst, app)); runs on every start, including restarts. Extensions use extkit.Extension instead, so they run on a single app too and pass the conformance test. |
Per-instance settings for an extension go under the instance's extensions.<name> (read them with pocker.DecodeExtensionConfig).
app.Restart() (a backup restore, pitr) restarts only that instance, not the process.
Timings for the Server-Timing header are registered with pocker.Timing(ctx, fn). The stock pocketone binary runs
pocker with servertiming, autotls and
mailtrap; the firewall turns on from its sections.
Platform values and logs
- Superusers (
superusers: [{email, password_hash, id?, token_key?}]) are upserted into the app's_superusersin Go when it starts, e.g. so a hosting platform's login works in the dashboard. Nothing goes through env. - Private values (
private: {...}) are for the host application's Go code and never visible to JS. A host registers that code withServer.SetupApp(inst, app), which runs after migrations and before the app serves (for example a backup route checked againstinst.Private["backup_token"]). - Secrets (
secrets: {...}) are the instance's environment: what its JS sees through$os.getenv/process.env, and nothing else. No names are reserved; platform settings are explicit fields. - Encryption key (
encryption_key) encrypts the app's stored settings (like--encryptionEnv). JS never sees it. - Access (
access: {cors_origins}) is the owner's say over which browser origins may call the instance (PocketBase matching: exact,*, wildcards; empty = any). Client IPs that get the trusted rate limits are the firewall'sextensions.firewall.trusted_ips(on top of the process-widefirewall.trusted_clients). - Extensions (
extensions: {<name>: ...}) are per-instance settings for extensions; extensions read them once, so changing them restarts the app on its next request. - Logs: the app's own logs (the dashboard's Logs) are per instance in its
pb_data.console.*from JS hooks goes to<instance>/logs/exec.logas JSON lines{message, stream, time}(stdoutfor log/info/debug,stderrfor warn/error), rotated toexec.log.1at 10 MiB. A start failure (e.g. a broken hooks file) is written there too, and only that instance fails.
When an instance's secrets, encryption_key, access, limits, dev, dir, superusers, private or remote changes, its app restarts on the next request (the old one finishes
in-flight requests first), so a rotated secret takes effect immediately. Host changes apply without a restart.
GET /_pocker/health on any host reports {status, instances, running}.
Changing an instance's files from host code (deploys, restores): Manager.WithInstanceStopped(ctx, id, fn) stops
the app, holds new requests for that instance, runs fn(dir), then lets them through to a fresh app.
Manager.StartInstance(ctx, id) starts it right away and returns the start error (for example to tell a deploy that
a hook is broken), Manager.Running(id) says whether it is started, and Manager.InstanceDir(id) gives its folder.
Manager.Ready() says whether the instance list has loaded. Manager.LogInstance(id, stream, message) appends to the instance's logs/exec.log (e.g. a scheduled job's result).
Host code that runs in its own goroutines gets the Manager from Server.OnStart, before any request is served.
Several servers, one set of instances. An instance with remote: <url> runs on another server: Pocker checks the
peer and the instance's rate limits here, then forwards the request (a reverse proxy to the URL by default;
Server.Forward replaces it, e.g. to hold requests while the other server restarts). Its app never starts here, and
an app already running here stops when remote is set. plain_listen gives the other servers a plain-HTTP port on a
private network, and Manager.Firewall() lets host routes in front of the Manager use the same peer check.
Sandbox
Every instance's JS hooks and migrations run in a sandbox (the sandbox extension), and its SQL runs behind
SQLite's authorizer (sandbox/sqlguard.go). The sandbox extension also owns the egress policy
(sandbox/egress.go); pocker turns it on for its instances. sandbox_test.go runs one attack per
route from one instance against another instance and the host; all of these are refused:
| Area | What the sandbox does |
|---|---|
Files: $os.*, $filepath.glob/walk*, $filesystem.fileFromPath, $template.loadFiles, $apis.static, require() |
Confined to the instance folder with an os.Root: the kernel resolves every path beneath it, refusing .. and symlinks that leave it. Relative paths and absolute paths inside the folder (__hooks + "/x") work as before. |
Host primitives: $os.cmd, $os.exec, $os.exit, $os.args, $filesystem.local |
Disabled ($os.args is empty). |
Environment: $os.getenv, process.env |
Only the instance's own secrets. |
Go internals: $app.restart/bootstrap/runAllMigrations/onServe/onTerminate/onBootstrap/cron/unsafeWithoutHooks, hook.bindFunc/bind/trigger/unbind*, *sql.DB/Conn/Tx/Stmt (e.g. dbx.DB.DB()), database/sql/driver, os/exec, net, syscall, http.Server controls |
Hidden from JS (the field-name mapper maps them to nothing). Top-level onRecord*, routerAdd, cronAdd and the rest of $app work as usual. |
Raw SQL ($app.db().newQuery) |
Allowed. SQLite's authorizer refuses ATTACH (and VACUUM INTO) outside the instance's pb_data, PRAGMA temp_store_directory/data_store_directory and load_extension; the CLI-only readfile/writefile/fsdir don't exist in the library. Statements without a deadline get max_query_duration. |
| CPU | Every JS call is interrupted after max_js_execution; sleep() is capped at 10 s. |
| Memory, one shot | Built-ins that allocate a large result in one step refuse results over max_js_allocation: String.repeat/padStart/padEnd, Array.join/toString, ArrayBuffer, typed arrays (incl. array-likes, .from, and reaching the constructor through an instance), Buffer.alloc* / new Buffer, $os.readFile, fileFromPath, readerToString/toBytes limits. Recursion depth is capped (a JS stack overflow would otherwise crash the Go process). |
| Memory, gradual | Not limited. goja has no per-VM memory accounting, so a hook's gradual growth counts against the whole process; the process can use the host's swap, and the OS is the last line. (A memory watch with strikes and quarantine was removed on 2026-11-14: it could fail requests where swap would have absorbed them, and it never fired in production.) |
Outbound network: $http.send, $filesystem.fileFromURL, S3 storage and backups, OAuth2/JWK fetches, SMTP |
Public addresses only, checked on the address actually connected to (after DNS, so rebinding doesn't help): loopback, private ranges, link-local and cloud metadata, CGNAT, multicast, reserved and documentation ranges, and IPv6 equivalents/IPv4-in-IPv6 are refused unless listed in egress_allow. Installed process-wide on Go's default HTTP transport and PocketBase's SMTP client (pocker/egress.go). Host code that must reach private addresses (e.g. a control plane over a private network) uses its own http.Client with its own transport. $http.send timeouts and response bodies are capped. |
Limits of the memory controls: only one-shot allocations are capped. A hook that grows memory gradually slows or, past swap, restarts the whole process (every instance in it) rather than failing alone; per-instance memory accounting needs a goja with a memory API, or one process per instance (the docker provider).
Not covered yet: per-instance concurrency limits, and OS-level defence in depth: run Pocker as a non-root user with an OS firewall that only lets that user reach public addresses (a second, independent egress layer), plus seccomp and Landlock.
Changes to PocketBase
These live in the repo root (PocketBase's own files), not in this extension.
Kept small and marked // PocketOne: so upstream merges stay easy:
core.BaseAppConfig.AppMigrations: a per-app migrations list (JS migrations fromplugins/jsvmregister there instead of the globalcore.AppMigrations).core.BaseAppConfig.EncryptionKey: the settings encryption key passed in directly instead of read from the process environment.plugins/jsvm:Config.Extensions(extension.go): values with optional methods (OnInit,OnCallaround every call into JS,HTTPClientfor$http.send,HookFiles/MigrationFiles/Require,OnLoad/OnCompilefor error mapping), plusRunandScriptPath. The sandbox, jsembed and jserrors are extensions.- Mail needs no core change: the sandbox sends an app's own SMTP through the egress policy on
OnMailerSend(sandbox.BindMail).
Versions
PocketOne has its own version line, starting at v0.0.1, independent of PocketBase's numbers. When PocketBase
releases, we merge it and decide then how to bump ours. Each release says which PocketBase version it's based on.
The module path stays ganigeorgiev.com/pocketbase/pocketbase, so a build pins a PocketOne version through the
replace line:
replace ganigeorgiev.com/pocketbase/pocketbase => github.com/pockethost/pocketone v0.0.1
Upstream tracking
Fetch upstream without its tags: PocketBase's v0.x tags must never land in this repo, or Go would take v0.41.1 for
the newest PocketOne.
git remote add upstream https://ganigeorgiev.com/git/pocketbase/pocketbase
git config remote.upstream.tagOpt --no-tags
git fetch upstream refs/tags/v0.41.2 # one release, without creating the tag here
git merge FETCH_HEAD # resolve conflicts in the PocketOne-marked spots, rerun `go test ./core/... ./plugins/jsvm/...` and `go test ./...` in `ext/multisite-provider-pocker` and `ext/sandbox`
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.