Table of Contents
- Dev – Service Manifest Reference
- 1. What a PIC service is
- 2. Manifest reference
- Top-level identity fields
- capabilities
- subdomain, extra_subdomains, backend, extra_backends
- containers
- config_schema
- peer_config_template
- accounts
- compose
- backup
- egress
- storage
- Store-only manifest fields
- 3. Compose template variables
- 4. Account provisioning interface
- How AccountManager works
- Provision flow
- Deprovision flow
- Admin API endpoints for account management
- How peer_config_template connects to stored credentials
- Container lifecycle routes
- Store service HTTP API
- 5. Backup integration
- 6. Egress routing
- 7. Quick-start example
- 8. Reference implementations
- Appendix: manifest field quick reference
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:
- Looks up the service in
ServiceRegistryand readsaccounts.managerfrom the manifest. - Dispatches to the appropriate internal manager method (for the reference services) or to the service's HTTP API endpoint (when
manageris"http"). Both dispatch paths are implemented. - Stores the returned credentials in
data/peer_service_credentials.jsonwith permissions0o600.
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
- Downloads this manifest from the store index.
- Validates every field (image allowlist, volume safety, reserved subdomains, iptables rule format).
- Verifies the image is digest-pinned and its cosign signature checks out (default
enforcemode). - Allocates a static IP from the service pool (
172.20.0.20–172.20.0.254). - Writes a Docker Compose file that starts
cell-homepagewith the allocated IP oncell-network. - Runs
docker compose up -d cell-homepage. - Applies the
iptables_rulesincell-wireguardso peers can reach the container. - Regenerates the Caddyfile so
home.<cell-domain>proxies tocell-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 |
Personal Internet Cell
New here?
Users
User – Connect to the VPN User – Use Your Services User – Troubleshooting
Admins
Admin – Overview Admin – Install and First Run Admin – Configure Domains and TLS Admin – Manage Services Admin – Configure Connectivity Admin – Manage Peers Admin – Back Up and Restore Admin – Logging and Audit Admin – Monitor and Troubleshoot
Developers
Dev – Overview Dev – Architecture Dev – Build a Store Service Dev – Service Manifest Reference Dev – API Reference Dev – Testing Dev – Install Internals
Decisions (ADRs)
ADR – 001 Store Images Are Signed and Verified by Cells ADR – 002 Named Connection Instances for Connectivity ADR – 003 All Optional Functionality Ships as Store Services
Meta
Meta – Glossary Meta – Template Runbook Meta – Template ADR
Archive
Archive – User Guide Archive – ADR 004 The Wiki Is the Single Documentation Source