Dev – Service Manifest Reference
Dmitrii Iurco edited this page 2026-06-11 15:39:28 -04:00

Status: Active | Owner: @roof | Applies to: main (2026-06) | Updated: 2026-06-11

Dev – Service Manifest Reference

The complete field-by-field reference for the PIC service manifest (schema_version: 3), the compose template variables, the account-provisioning HTTP interface, backup integration, and egress routing. For the narrative walkthrough and submission process, see Dev – Build a Store Service.

Prerequisites: Docker, Docker Compose, basic Linux networking. You do not need to know Python to build a store service.


1. What a PIC service is

A PIC service is a Docker container (or a set of containers) that plugs into the PIC ecosystem through a single JSON file called the manifest. The manifest tells PIC everything it needs to know:

  • How to route HTTPS traffic to the service through Caddy
  • What subdomains to expose
  • Which users get accounts on the service and what credentials they receive
  • Which paths to include in automated backups
  • Which outbound network interfaces the service is allowed to use

All PIC services are store services — optional packages installed by the cell admin from the pic-services catalog. PIC downloads the manifest, renders a per-service Docker Compose file, verifies the image signature with cosign, and starts the containers. The core PIC stack (DNS, NTP, WireGuard, Caddy, API, WebUI) runs independently of any installed services.

The email, calendar, and files services (in pic-services/services/) are the reference implementations and show the full feature set. The ServiceRegistry in api/service_registry.py is the single source of truth for all installed services. CaddyManager, the backup system, and the peer services endpoint all read from it rather than from hardcoded lists.


2. Manifest reference

The manifest is a JSON file with "schema_version": 3. The email, calendar, and files manifests in pic-services/services/ are the canonical reference examples.

Top-level identity fields

Field Type Required Description
schema_version integer yes Must be 3.
id string yes Unique service identifier, lowercase, no spaces (for example, "notes"). Must match the store index entry.
name string yes Human-readable display name (for example, "Notes").
description string yes One-sentence description shown in the UI.
version string yes Semver string for the service package itself (for example, "1.0.0").
author string yes Your name or organisation.
kind string yes Must be "store".
min_pic_version string no Minimum PIC version required (for example, "1.0").
{
  "schema_version": 3,
  "id": "notes",
  "name": "Notes",
  "description": "Self-hosted Markdown notes with full-text search",
  "version": "1.0.0",
  "author": "acme",
  "kind": "store",
  "min_pic_version": "1.0"
}

capabilities

A set of boolean flags that tell PIC which integrations to activate for your service.

Field Type Default Description
has_subdomain bool false The service gets a subdomain and a Caddy reverse-proxy route. Requires subdomain and backend.
has_accounts bool false The service provisions per-peer accounts. Requires accounts.
has_admin_config bool false The service has admin-configurable fields. Requires config_schema.
has_storage bool false The service has data worth backing up. Requires backup.
has_egress bool false The admin can choose which outbound connection this service uses. Requires egress.
has_api_hooks bool false Reserved for future use; set false.

subdomain, extra_subdomains, backend, extra_backends

These fields are only read when has_subdomain is true.

Field Type Required Description
subdomain string yes (if has_subdomain) The primary subdomain (for example, "notes"). Results in notes.<cell-domain>. Must not collide with reserved names: api, webui, admin, www, ns1, ns2, git, registry, install.
extra_subdomains array of strings no Additional subdomains that point to the same backend (for example, ["webmail"]).
backend string yes (if has_subdomain) The container-name:port combination that Caddy proxies to (for example, "cell-notes:8080"). Uses Docker DNS on the cell-network.
extra_backends object no Maps extra subdomain names to separate backends. Key is the subdomain string; value is the backend string. The email service uses this to send webdav.* to a different container than files.*.

Validation at runtime: ServiceRegistry.get_caddy_routes() validates all subdomain and backend values before passing them to CaddyManager or NetworkManager. Any entry whose subdomain does not match ^[a-z][a-z0-9-]{0,30}$, whose backend does not match ^[A-Za-z0-9._-]+:\d{1,5}$, or whose subdomain appears in the reserved list is silently skipped with a warning log. The same validation applies to extra_subdomains and extra_backends keys/values. For store services, this validation is also performed during installation by ServiceStoreManager._validate_manifest().

containers

Array of container names that belong to this service. Used by the UI and log viewer.

"containers": ["cell-notes"]

config_schema

Defines admin-configurable fields for this service. When has_admin_config is true, the UI renders a settings form from this schema. PIC stores admin-saved values in cell_config.json and merges them with your default values at runtime. The merged result is available as the config key when ServiceRegistry.get() returns your service.

Each field is an object:

Key Type Required Description
type string yes One of "string", "integer", "boolean".
label string yes Human-readable label for the settings form.
required bool no Whether the field must have a value before the service starts.
default any no Default value used when the admin has not set one.
min / max integer no (integer only) Inclusive bounds for integer fields.
"config_schema": {
  "port": {
    "type": "integer",
    "label": "Internal HTTP port",
    "default": 8080,
    "min": 1,
    "max": 65535
  }
}

peer_config_template

When a peer is provisioned on this service, PIC fills this template and returns the result to the peer as their connection info. Template substitution tokens:

Token Replaced with
{domain} The cell's public domain (for example, alice.pic.ngo)
{peer.username} The peer's username
{peer.service_credentials.<id>.<field>} A credential value; <id> is the service id, <field> matches a name in accounts.credentials
{config.<key>} A value from the merged config_schema result
"peer_config_template": {
  "url": "https://notes.{domain}/",
  "username": "{peer.username}",
  "password": "{peer.service_credentials.notes.password}"
}

accounts

Required when has_accounts is true.

Field Type Description
manager string Set to "http" for store services — PIC calls your container's HTTP API for account operations (see section 4). The reference services (email, calendar, files) use internal manager names ("email_manager", "calendar_manager", "file_manager").
credentials array of strings Names of credential fields this service issues per peer. Most services use ["password"]. The names appear in peer_config_template tokens.

compose

Unused at the manifest level. Compose configuration is provided via compose-template.yml in the service package (see section 3). Set to null in the manifest.

backup

Required when has_storage is true. Tells PIC's backup system what to snapshot.

Field Type Description
volumes array of objects Container paths to stream out via docker exec tar. Each entry has three string fields: container (container name), path (absolute path inside the container), and name (archive filename stem).
config_paths array of strings Paths relative to the PIC project root on the host that contain service configuration (not user data). Copied directly into the snapshot.

Each entry in volumes produces an archive at <name>.tar.gz inside the snapshot. See section 5 for details.

egress

Required when has_egress is true. Declares which outbound paths this service is permitted to use.

Field Type Description
default string The path selected when the admin has not changed anything.
allowed array of strings The complete set of connection types the admin can choose from.

Valid identifiers: default, wireguard_ext, openvpn, tor, sshuttle, proxy. See section 6.

storage

Informational metadata used by the UI to show storage usage.

Field Type Description
primary_path string The path (relative to project root) that holds the bulk of user data.
quota_mb integer or null Storage quota in megabytes; null means no limit.

Store-only manifest fields

These are validated by ServiceStoreManager._validate_manifest() before installation is permitted.

Field Type Required Description
image string yes Docker image to pull. Must match the pattern git.pic.ngo/roof/*. Images from other registries are rejected. In the published store, the image reference is digest-pinned (@sha256:...) and cosign-signed by the build pipeline; under the default enforce verification mode, an undigested or unsigned image will not install.
container_name string yes The name Docker gives the running container. Must match ^cell-[a-z0-9][a-z0-9-]{0,30}$.
volumes array no Named volumes to mount. Each entry must have name (the volume name) and mount (the absolute path inside the container). Mounts to /, /etc, /var, /proc, /sys, /dev, /app, /run, /boot, and paths that are a prefix of the PIC project root are forbidden.
env array no Environment variables to pass. Each entry has key and value. Values must match ^[A-Za-z0-9._@:/+\-= ]*$.
iptables_rules array no FORWARD ACCEPT rules PIC should install in cell-wireguard. Each rule must have type: "ACCEPT", dest_ip: "${SERVICE_IP}", an integer dest_port (1–65535), and an optional proto ("tcp" or "udp", default "tcp"). The literal string ${SERVICE_IP} is replaced with the allocated container IP at install time.
caddy_route object no If the service exposes a web UI, provide subdomain (must not be reserved; must match ^[a-z][a-z0-9-]{0,30}$). PIC inserts the corresponding reverse_proxy directive into the Caddyfile.

3. Compose template variables

When you ship a store service, you include a compose-template.yml alongside your manifest.json. ServiceComposer.render_template() substitutes the variables below before writing the per-service docker-compose.yml.

Variable Syntax Value
${PIC_CFG_<KEY>} uppercase config_schema key The admin-saved value for that field, or the default from the schema if the admin has not set it. For example, config_schema.port${PIC_CFG_PORT}.
${PIC_SECRET_<NAME>} any name you choose An auto-generated random secret produced by secrets.token_urlsafe(24) (~32 URL-safe base64 characters). Generated once on first install, then reused unchanged on every reconfigure. Stored per service in data/service_secrets.json.
${PIC_DOMAIN} literal Effective domain from ConfigManager (for example, alice.pic.ngo).
${PIC_CELL_NAME} literal Cell name from the identity config (for example, alice).
${PIC_SERVICE_ID} literal The id field from the service manifest (for example, notes).
${SERVICE_IP} literal The static IP PIC allocated from the service pool. Always set automatically.
${INSTANCE_ID} literal For instanceable connectivity services only: the short connection-instance id, used to derive per-instance container names.

Volume mounts: Because docker compose runs inside the API container but the Docker daemon runs on the host, relative volume paths in compose templates resolve relative to the compose file's directory as seen by the HOST filesystem. To avoid path resolution surprises, prefer named volumes for service data (Docker manages them independently).

Example compose-template.yml:

services:
  cell-notes:
    image: git.pic.ngo/roof/pic-notes@sha256:<digest>
    container_name: cell-notes
    restart: unless-stopped
    environment:
      NOTES_PORT: "${PIC_CFG_PORT}"
      NOTES_DOMAIN: "${PIC_DOMAIN}"
      NOTES_DB_PASS: "${PIC_SECRET_DB_PASSWORD}"
    volumes:
      - notes-data:/data/notes
    networks:
      cell-network:
        ipv4_address: "${SERVICE_IP}"

volumes:
  notes-data:

networks:
  cell-network:
    external: true

4. Account provisioning interface

How AccountManager works

AccountManager (api/account_manager.py) is the single entry point for all account operations across every service type. When a peer account is provisioned, it:

  1. Looks up the service in ServiceRegistry and reads accounts.manager from the manifest.
  2. Dispatches to the appropriate internal manager method (for the reference services) or to the service's HTTP API endpoint (when manager is "http"). Both dispatch paths are implemented.
  3. Stores the returned credentials in data/peer_service_credentials.json with permissions 0o600.

Credentials are stored in plaintext. This is intentional: the peer credentials endpoint needs to return them verbatim for one-time client configuration. The 0o600 permission matches the pattern used for WireGuard keys and data/service_secrets.json. Writes use a write-then-rename pattern with os.fsync to avoid partial-write corruption.

Provision flow

POST /api/services/catalog/<service_id>/accounts
Content-Type: application/json

{ "username": "alice", "password": "optional" }

If password is omitted, AccountManager generates one with secrets.token_urlsafe(16). The response on HTTP 201 is:

{ "service_id": "email", "username": "alice", "provisioned": true }

The password is not echoed in the response. To retrieve stored credentials for a provisioned peer, call GET /api/services/catalog/<id>/accounts/<username>/credentials.

If the underlying manager call fails, provision() raises and the route returns HTTP 500. For email, the domain is read from the service's merged config (svc['config']['domain']); if absent, provisioning raises ValueError before calling the manager.

Deprovision flow

DELETE /api/services/catalog/<service_id>/accounts/<username>

Removes the account on the service and the peer's entry from the credentials file. Returns HTTP 200 on success, HTTP 400 if the service does not exist or does not support accounts.

Peer deletion calls AccountManager.deprovision_peer(peer_username), which iterates over every service the peer is provisioned on. Failures on individual services are logged and skipped rather than aborting the deletion.

Admin API endpoints for account management

Method Path Description
GET /api/services/catalog/<service_id>/accounts List provisioned usernames.
POST /api/services/catalog/<service_id>/accounts Provision a peer account (HTTP 201).
DELETE /api/services/catalog/<service_id>/accounts/<username> Deprovision.
GET /api/services/catalog/<service_id>/accounts/<username>/credentials Stored credentials for one peer+service pair (404 if not provisioned).
GET /api/peers/<peer_name>/service-credentials Filled peer_config_template values for all services the peer is provisioned on.

The Email, Calendar, and Files service pages each have an Accounts tab in the admin dashboard that calls these endpoints.

How peer_config_template connects to stored credentials

GET /api/peers/<peer_name>/service-credentials is the endpoint a peer device calls during first-time setup to configure email, CalDAV, and file sync clients. For each service the peer is provisioned on, ServiceRegistry.get_peer_service_info() fills the peer_config_template tokens ({domain}, {peer.username} URL-encoded, {peer.service_credentials.<id>.<field>}, {config.<key>}) and returns the filled dict. If a service has no peer_config_template, the raw credential dict is returned as a fallback.

Container lifecycle routes

Method Path Description
GET /api/services/catalog/<id>/status Container status (one entry per container).
POST /api/services/catalog/<id>/restart Restart the service containers via the service's own compose project.
POST /api/services/catalog/<id>/reconfigure Re-render the compose file from the template and re-apply with up -d (rolling update). Body must include compose_template.

Store service HTTP API

When accounts.manager is "http", PIC calls your container's HTTP API for account operations. The base path is /service-api/accounts on your container's internal address. There is no authentication on this API — it is reachable only from within the cell-network Docker network.

Create account

POST /service-api/accounts
Content-Type: application/json

{ "username": "alice", "password": "auto-generated-by-pic" }

PIC generates the password and passes it to your service. Return HTTP 200 with {"ok": true} on success. Return HTTP 400 or 409 with {"ok": false, "error": "..."} for expected errors (duplicate username, invalid input). Return HTTP 500 for unexpected internal errors.

Delete account

DELETE /service-api/accounts/{username}

Return HTTP 200 with {"ok": true} on success, HTTP 404 with {"ok": false, "error": "not found"} if the account does not exist.

List accounts

GET /service-api/accounts

Return {"accounts": ["alice", "bob"]}.


5. Backup integration

Declare has_storage: true in capabilities and fill in the backup block. ServiceRegistry.get_backup_plan() returns the combined backup declarations for all installed services; the backup runner reads from that method.

Why docker exec instead of bind mounts

The API container only has access to data/api/ on the host filesystem. Service data (mailboxes, calendar collections, file trees) lives in other containers' volumes. Rather than mount every service volume into the API container, PIC streams data using docker exec <container> tar czf - <path>. This works for any container on the Docker host regardless of how its volumes are configured.

volumes entries

Field Description
container Name of the running container to exec into (for example, "cell-notes").
path Absolute path inside that container to archive (for example, "/data/notes").
name Archive filename stem. PIC saves the archive as <name>.tar.gz under service_data/<service_id>/ in the backup directory.

A service with multiple containers or multiple data directories lists one entry per directory.

Security note: The backup commands use docker exec -- <container> tar -C <path> -czf - . (note the -- separator) to prevent option injection. The container name is validated against ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$ before the command is run.

config_paths

Paths relative to the PIC project root on the host, copied directly into the snapshot (no docker exec). Use this for configuration files the service reads at startup, not for user data.

Restore

PIC restores each volume entry by piping the archive back via docker exec -i -- <container> tar -C <path> -xzf -. The -C <path> flag bounds extraction to the declared volume path. The target container must be running at restore time.


6. Egress routing

When has_egress is true, the cell admin can route your service's outbound traffic through a named connection instance (Connectivity v2): a WireGuard external tunnel, OpenVPN, Tor, an SSH tunnel (sshuttle), or an upstream HTTP/SOCKS5 proxy.

The manifest's egress.allowed lists the connection types the admin may choose from: default, wireguard_ext, openvpn, tor, sshuttle, proxy. At assignment time the admin picks a concrete connection instance of one of those types; PIC enforces the selection with per-instance fwmarks (allocated from 0x1000 upward) and policy-routing tables (from 1000 upward) inside the cell-wireguard container.

Always include default in allowed so the admin has a way to use the normal path. If the chosen connection is unhealthy, traffic is dropped by the kill-switch FORWARD rule (fail-closed) unless the assignment is configured fail-open. See Admin – Configure Connectivity.


7. Quick-start example

A minimal working example: a static website served from Nginx with no accounts, no backup, and no egress policy.

manifest.json

{
  "schema_version": 3,
  "id": "homepage",
  "name": "Homepage",
  "description": "A static homepage served from your cell",
  "version": "1.0.0",
  "author": "acme",
  "kind": "store",
  "min_pic_version": "1.0",

  "capabilities": {
    "has_subdomain": true,
    "has_accounts": false,
    "has_admin_config": false,
    "has_storage": false,
    "has_egress": false,
    "has_api_hooks": false
  },

  "subdomain": "home",
  "extra_subdomains": [],
  "backend": "cell-homepage:80",

  "containers": ["cell-homepage"],

  "image": "git.pic.ngo/roof/pic-homepage@sha256:<digest>",
  "container_name": "cell-homepage",

  "volumes": [
    { "name": "homepage-html", "mount": "/usr/share/nginx/html" }
  ],

  "env": [],

  "iptables_rules": [
    {
      "type": "ACCEPT",
      "dest_ip": "${SERVICE_IP}",
      "dest_port": 80,
      "proto": "tcp"
    }
  ],

  "caddy_route": {
    "subdomain": "home"
  },

  "compose": null
}

What PIC does on install

  1. Downloads this manifest from the store index.
  2. Validates every field (image allowlist, volume safety, reserved subdomains, iptables rule format).
  3. Verifies the image is digest-pinned and its cosign signature checks out (default enforce mode).
  4. Allocates a static IP from the service pool (172.20.0.20172.20.0.254).
  5. Writes a Docker Compose file that starts cell-homepage with the allocated IP on cell-network.
  6. Runs docker compose up -d cell-homepage.
  7. Applies the iptables_rules in cell-wireguard so peers can reach the container.
  8. Regenerates the Caddyfile so home.<cell-domain> proxies to cell-homepage:80.

The result: any WireGuard peer can reach https://home.alice.pic.ngo/ immediately after installation.


8. Reference implementations

The email, calendar, and files services in pic-services/services/ are the canonical examples of a complete store service:

Service Notable features demonstrated
email has_accounts, has_egress, multi-container (cell-mail + webmail), extra_backends, custom image baking defaults via Dockerfile
calendar has_accounts, CalDAV peer_config_template, htpasswd account provisioning
files has_accounts, has_storage, WebDAV + Filegator extra_backends, backup.volumes with multiple entries

Appendix: manifest field quick reference

Field Required Notes
schema_version yes Must be 3
id yes
name yes
description yes
version yes
author yes
kind yes Must be "store"
min_pic_version no
capabilities.* yes All six flags must be present
subdomain if has_subdomain
extra_subdomains no
backend if has_subdomain
extra_backends no
containers no Informational
config_schema if has_admin_config
peer_config_template if has_accounts
accounts if has_accounts
compose no Always null — compose config goes in compose-template.yml
backup if has_storage
egress if has_egress
storage if has_storage
image yes git.pic.ngo/roof/*; digest-pinned and signed in the published store
container_name yes Must match ^cell-[a-z0-9][a-z0-9-]{0,30}$
volumes no
env no
iptables_rules no
caddy_route no