Docker

Run every instance on a host in its own container, with the official PocketBase or PocketOne binary of the version the instance asks for. One host can serve PocketBase 0.23 and 0.36 and PocketOne side by side.

Docker is one of the two runtime providers of the multi-instance multisite. The other is Pocker, which runs every instance inside one process. A host runs exactly one of them.

Install · Configure · Use · Integrations · Example

Install

Docker is built into pocketone. It needs a Docker daemon on the same machine; nothing else to install.

It's off until pocketone.yaml names it as the provider (below). Then pocketone serve runs the host instead of a single app.

In your own Go build, register it as a provider of the host before building the host from the config:

multisite.RegisterProvider(&docker.Provider{Resolve: cfg.ResolvePath})
h, err := multisite.FromConfig(multisite.Options{Resolve: cfg.ResolvePath})

The module is github.com/pockethost/pocketone/ext/multisite-provider-docker. Add docker.NewCommand(...) to your root command for the docker seed and docker update commands.

Configure

Set provider: docker and a docker section in pocketone.yaml. Listeners (server), instances (instances) and the firewall are the host's sections.

provider: docker
docker:
  image: alpine:3.20                 # base image the binaries run in
  binaries: /var/lib/pocketbase      # release binary cache
  root: /var/lib/instances           # one folder per instance: <root>/<id>
  versions: ["pb:0.23", "pb:0.36", "p1:0.0"]
  default_version: "pb:0.36.*"
  idle_ttl: 10m
  max_concurrent_starts: 4
instances:
  file: ./instances.yaml

image, binaries and root are required.

Versions. An instance picks its binary with the prefix of its version: pb: is upstream PocketBase, p1: is PocketOne, no prefix means pb:. versions lists the minors the host serves; the newest cached patch that matches wins. A version that isn't listed gets a clear 503. Upstream PocketBase 0.22 and older are refused unless you lower min_upstream and keep those releases in the cache yourself.

Per instance (instances.yaml or a mothership): version, secrets (passed as environment), encryption_key, dev, and dir to put its folder somewhere other than <root>/<id>. An instance with runtime set to anything but docker isn't served here.

Limits and hardening are optional: nano_cpus, pids_limit, nofile, cap_drop and no_new_privileges.

pocketone.yaml reference

docker:

Runtime provider docker: each instance in its own container with the PocketBase (pb:) or PocketOne (p1:) binary of its version (image, binary cache and manifest, instance root, default version, idle stop, limits).

Key Type Default Description
socket string The Docker socket. Default $DOCKER_HOST when it is unix://, else /var/run/docker.sock.
image string Base image the binaries run in, such as alpine:3.20. Required; pulled when missing.
binaries string Folder of the release binary cache and its releases.json. Required.
versions list of string The minors this host serves, such as pb:0.36 or p1:0.0 (latest patch of each, from docker update).
root string Folder with one folder per instance (/). Required.
home string The instance folder inside the container. Default /home/pockethost.
default_version string Version for instances that name none, such as p1:0.0.*. Empty: such instances fail.
min_upstream string Oldest upstream PocketBase release served. Default 0.23.0; lower it only for releases you cache yourself.
idle_ttl duration Stop a container after this long without requests. Default 10m.
max_concurrent_starts int Containers starting at once. Default 4.
start_timeout duration How long a container gets to answer its health check. Default 60s.
nano_cpus int CPU limit per container in billionths of a CPU (1000000000 = one CPU). 0: no limit.
pids_limit int Process limit per container. 0: Docker's default.
nofile list of int Open-file limit per container: [soft, hard]. Empty: Docker's default.
cap_drop list of string Linux capabilities to drop, such as [ALL].
no_new_privileges bool Set no-new-privileges on every container.
network string Docker network for the containers. Default the bridge.
adopt_image string Also adopt running unlabeled containers of this image named after an instance id (taking over from another manager).

Use

Fetch the binaries

pocketone docker update   # find the latest patch of each docker.versions minor, record and download it
pocketone docker seed     # download whatever is recorded but not in the cache yet

update checks upstream PocketBase releases (ganigeorgiev.com, then the archived GitHub) and PocketOne releases on GitHub, verifies each download against the release's checksums.txt, and records the list in the cache's releases.json. Run it when you want new patches. Nothing is downloaded at request time: when the host starts, any listed release missing from the cache is downloaded in the background.

The cache layout is <binaries>/<version>/<platform>/pocketbase for PocketBase (PocketHost's existing layout) and <binaries>/p1/<version>/<platform>/pocketone for PocketOne.

Run the host

pocketone serve
  • A container starts on an instance's first request and is ready when /api/health answers. At most max_concurrent_starts start at once.
  • It stops after idle_ttl without requests.
  • It restarts on its next request when what it runs changed (secrets, version, mounts). A change to routing only doesn't restart it.
  • It stops when the instance is deleted or moves to another edge.
  • Containers keep running when the host restarts; the next process adopts them by their labels.

Hooks and migrations

A change in an instance's pb_hooks restarts its container. PocketBase's own hooks watcher stays on too, so a hook file that fails to load is logged and skipped instead of stopping the instance. A request that arrives while the instance restarts waits for it instead of failing; one that lost its connection mid-restart is sent once more if it's safe to repeat (GET, HEAD or OPTIONS without a body). Migrations run when a container boots.

Maintenance

Instance folders are ordinary folders under root. To work on one while nothing has it open, a host's Go code calls WithInstanceStopped (below); requests for that instance wait up to 30 s, then get a 503 with Retry-After.

Switching provider

Docker and Pocker both use <root>/<id>, so switching is a config change. Stop the containers first when leaving docker, since they keep running across a host restart on purpose. Data only moves forward: an instance started on a newer PocketBase than wrote its data is upgraded.

Integrations

Works with

Extension How they fit
Multisite Docker is a runtime provider of the host: the host routes by hostname, runs the listeners and gates, and hands each request to docker.
Pocker The other provider. A host runs one or the other, over the same instance folders.
Edge For a fleet: requests for instances on another edge are forwarded there.
Mothership Instances (and their versions) come from a mothership instead of a file.
Firewall Rate limits and allow lists in front of every instance.

Building on Docker

  • docker.Provider fields extend the provider from Go: Container adds mounts, environment and labels to each container; Version rewrites an instance's version before the binary is picked; OnLog receives container output (default: the debug log); Versions replaces the versions list (for example a fleet's list from a mothership); MinUpstream replaces min_upstream.
  • Spec (what Container fills in): Binds, Env, Labels, plus Cmd to adjust the command (for example drop a flag an old release lacks) and HealthPath / HealthAnyStatus for the readiness check.
  • VersionsSource: any type with Releases(ctx) decides which releases the host serves, each pinned to its download and sha256. YAMLVersions (the versions key) is the built-in one.
  • Runtime API: docker.New(docker.Config{...}) builds the runtime without the config file. WithInstanceStopped(ctx, id, fn) stops an instance, holds its requests and runs fn on its folder (for deploys and restores); StartInstance starts one now; InstanceDir returns its folder.
  • Release helpers: ParseVersion, Pick / PickMin, LatestPatches, Update and Seed are what the commands use.

Example

A minimal docker host: instances from a YAML file, each in its own container, routed by hostname on one listener. With a Docker daemon running, start it from this folder with go run ./example --instances instances.yaml --data ./instances --binaries ./bin --versions pb:0.23,p1:0.0.

// A minimal docker host: instances from a YAML file, each in its own container with the PocketBase or PocketOne
// version it asks for, routed by hostname on one listener.
//
//	go run ./example --instances instances.yaml --data ./instances --binaries ./bin --versions pb:0.23,p1:0.0
//
// instances.yaml:
//
//	instances:
//	  - { id: blog, hosts: [blog.localhost], version: "pb:0.23.*" }
//	  - { id: shop, hosts: [shop.localhost], version: "p1:0.0.*" }
package main

import (
	"context"
	"flag"
	"log/slog"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"github.com/pockethost/pocketone/ext/multisite"
	"github.com/pockethost/pocketone/ext/multisite-provider-docker"
)

func main() {
	instances := flag.String("instances", "instances.yaml", "instances file")
	data := flag.String("data", "./instances", "folder with one folder per instance")
	binaries := flag.String("binaries", "./bin", "PocketBase and PocketOne binary cache")
	versions := flag.String("versions", "pb:0.23,p1:0.0", "minors to serve (latest patch each; fetched at start)")
	image := flag.String("image", "alpine:3.20", "base image")
	listen := flag.String("listen", "127.0.0.1:8090", "listen address")
	flag.Parse()

	b := &docker.Binaries{Root: *binaries}
	tracked := strings.Split(*versions, ",")
	if err := docker.RunUpdate(context.Background(), os.Stdout, docker.DefaultDiscovery(), b, tracked); err != nil {
		slog.Error("versions", "error", err)
		os.Exit(1)
	}
	rt, err := docker.New(docker.Config{Image: *image, DataRoot: *data, Binaries: b, Versions: docker.YAMLVersions{Tracked: tracked, Root: b.Root}})
	if err != nil {
		slog.Error("docker", "error", err)
		os.Exit(1)
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	h := &multisite.Host{Source: &multisite.YAMLSource{Path: *instances}, Runtime: rt, Listeners: multisite.Listeners{Listen: *listen}}
	if err := h.Run(ctx); err != nil {
		slog.Error("host", "error", err)
		os.Exit(1)
	}
}

How it works

  • Each instance folder is mounted at /home/pockethost and the binary read-only at /home/pockethost/pocketbase. The container runs ./pocketbase serve --http 0.0.0.0:8090, plus --dev for dev instances and --encryptionEnv=PB_ENCRYPTION_KEY when the instance has an encryption key. Its secrets are its environment.
  • The port is published on 127.0.0.1 and the host proxies to it. This is the layout PocketHost's Node edge uses, so its instance folders and binary cache move as they are.
  • Every container carries labels: p1.runtime, p1.instance, p1.version (the resolved release, such as pb:0.36.9) and p1.spec (a hash of its configuration, used to tell when it must restart). adopt_image also adopts unlabeled containers of an image, for taking over from another manager.
  • Docker is reached over its socket API directly; there's no Docker SDK dependency.

Status

Tested against a real Docker daemon, with upstream PocketBase 0.23 and PocketOne 0.0.1 side by side, alone and as a host's runtime. Not yet in production.

go test ./... runs the Docker tests when a daemon is reachable (they're skipped with -short). The first run downloads an upstream release and a PocketOne release into the user cache.

License

MIT