Steps 3-6 were implemented since this doc was last written. Several
technical details had drifted from the actual code:
- Provision response shape was shown as echoing the password; corrected
to {provisioned: true} to match the security model (passwords are
never returned after creation)
- Restore command flag corrected from -C / to -C <path>; archives use
relative paths so the extraction target must be explicit
- Added ServiceRegistry validation chokepoint note: subdomain and
backend are validated at registration time, before Caddyfile
generation, not at request time
- Added Admin UI note: Accounts tab appears on service pages
- Added -- separator security note for backup command construction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
34 KiB
PIC Service Developer Guide
This guide is for developers who want to build services that integrate with Personal Internet Cell (PIC). It covers the manifest format, how PIC wires up routing, DNS, backup, and account provisioning for your service, and how to package and submit your work.
Prerequisites: you should be comfortable with Docker, Docker Compose, and basic Linux networking. You do not need to know Python to build a store service.
Table of Contents
- What a PIC service is
- Manifest reference
- Compose template variables
- Account provisioning interface
- Backup integration
- Egress routing
- Quick-start example
- Submitting to the store
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
PIC has two kinds of services:
Builtins ship with PIC itself (email, calendar, files). Their containers are declared in the main docker-compose.yml. Their manifests live under api/services/builtins/<id>/manifest.json. They use Python manager classes for account operations because they run inside the same Docker network and share internal access.
Store services are third-party packages installed by the cell admin from the PIC service store. PIC downloads the manifest from the store index, allocates a static IP for the container in the service pool (172.20.x.x, offsets 20–254 within your ip_range subnet), generates a Docker Compose override file, and starts the container. Store services use a small HTTP API (described in section 4) for account operations.
The ServiceRegistry in api/service_registry.py is the single source of truth for both kinds. 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. Every field is described below. The three builtin manifests (email, calendar, files) are the canonical 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 (e.g. "notes"). Must match the directory name for builtins, or the store index entry for store services. |
name |
string | yes | Human-readable display name (e.g. "Notes"). |
description |
string | yes | One-sentence description shown in the UI. |
version |
string | yes | Semver string for the service package itself (e.g. "1.0.0"). |
author |
string | yes | Your name or organisation. |
kind |
string | yes | "builtin" for built-in services, "store" for third-party packages. |
min_pic_version |
string | no | Minimum PIC version required (e.g. "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 interface this service uses. Requires egress. |
has_api_hooks |
bool | false |
Reserved for future use; set false. |
"capabilities": {
"has_subdomain": true,
"has_accounts": true,
"has_admin_config": false,
"has_storage": true,
"has_egress": false,
"has_api_hooks": 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 (e.g. "notes"). Results in notes.<cell-domain>. Must not collide with reserved names: api, webui, admin, www, mail, ns1, ns2, git, registry, install. |
extra_subdomains |
array of strings | no | Additional subdomains that point to the same backend (e.g. ["webmail"]). |
backend |
string | yes (if has_subdomain) |
The container-name:port combination that Caddy proxies to (e.g. "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.*. |
"subdomain": "notes",
"extra_subdomains": [],
"backend": "cell-notes:8080"
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. For builtins this is informational; for store services PIC only manages the single container declared in the manifest.
"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
},
"storage_path": {
"type": "string",
"label": "Data directory inside container",
"default": "/data/notes"
}
}
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 (e.g. 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 | For builtins: the Python manager name used internally ("email_manager", "calendar_manager", "file_manager"). For store services: set to "http" to indicate the HTTP API (section 4). |
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. |
"accounts": {
"manager": "http",
"credentials": ["password"]
}
compose
For builtins set this to null — their containers come from the main docker-compose.yml. For store services this field is unused at the manifest level; compose configuration is provided via compose-template.yml in the package (see section 3).
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. For example, "name": "maildata" produces maildata.tar.gz.
"backup": {
"volumes": [
{"container": "cell-notes", "path": "/data/notes", "name": "notes_data"}
],
"config_paths": ["config/notes"]
}
ServiceRegistry.get_backup_plan() aggregates these declarations across all installed services. The backup runner reads from that method rather than from any hardcoded list.
egress
Required when has_egress is true. Declares which outbound network interfaces this service is permitted to use.
| Field | Type | Description |
|---|---|---|
default |
string | The interface selected when the admin has not changed anything. |
allowed |
array of strings | The complete set of interfaces the admin can choose from. |
Valid interface identifiers: cell_internet, openvpn, wireguard, tor, pic_cell.
"egress": {
"default": "cell_internet",
"allowed": ["cell_internet", "openvpn", "wireguard", "tor", "pic_cell"]
}
How enforcement works is described in 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. |
"storage": {
"primary_path": "data/notes",
"quota_mb": null
}
Store-only manifest fields
Store services (where kind is "store") have additional required fields that builtins do not use. 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. |
container_name |
string | yes | The name Docker gives the running container. |
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
This section applies only to store services. Builtins define their containers directly in docker-compose.yml.
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 (e.g. alice.pic.ngo). |
${PIC_CELL_NAME} |
literal | Cell name from the identity config (e.g. alice). |
${PIC_SERVICE_ID} |
literal | The id field from the service manifest (e.g. notes). |
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). If bind mounts are required, use absolute host paths with ${PIC_PROJECT_DIR} once that variable is implemented, or document the expected host layout clearly.
Example compose-template.yml for a notes service:
services:
cell-notes:
image: git.pic.ngo/roof/pic-notes:latest
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
The SERVICE_IP variable is the IP PIC allocated from the service pool. It is always set automatically.
4. Account provisioning interface
This section covers two related things: the AccountManager class that is PIC's central credential dispatcher, and the HTTP API that store services must implement to receive account operations.
How AccountManager works
AccountManager (api/account_manager.py) is the single entry point for all account operations across every service type. It is instantiated once in api/managers.py and holds references to the three builtin service managers (email_manager, calendar_manager, file_manager).
When a peer account is provisioned, AccountManager:
- Looks up the service in
ServiceRegistryand readsaccounts.managerfrom the manifest. - Dispatches to the appropriate internal manager method (for builtins) or to the service's HTTP API endpoint (for store services — not yet implemented;
"http"manager support is planned). - 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.
The credentials file structure is:
{
"<service_id>": {
"<peer_username>": { "password": "..." }
}
}
Writes use a write-then-rename pattern (tmp → final path) with os.fsync to avoid partial-write corruption.
Manifest accounts field
The accounts block in the manifest wires a service into AccountManager.
| Field | Type | Description |
|---|---|---|
manager |
string | Which underlying manager handles account operations. For builtins: "email_manager", "calendar_manager", or "file_manager". |
credentials |
array of strings | Names of the credential fields this service issues per peer. Most services use ["password"]. These names are used as token keys in peer_config_template. |
"accounts": {
"manager": "email_manager",
"credentials": ["password"]
}
The manager value must match a key that AccountManager was instantiated with. If the manager name has no registered dispatch entry, provision() raises ValueError immediately.
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.
Internally, AccountManager.provision(service_id, peer_username, password):
- Resolves the service and its manager via
_resolve_service(). - Calls the appropriate
_provision_*method, which delegates to the concrete manager:email_manager→create_email_user(username, domain, password)calendar_manager→create_calendar_user(username, password)file_manager→create_user(username, password)
- Stores
{"password": "<value>"}under[service_id][peer_username]in the credentials file. - Returns the credential dict to the caller.
If the underlying manager call returns False, provision() raises RuntimeError. The route handler maps this to HTTP 500.
For email, the domain is read from the service's merged config (svc['config']['domain']). If that key is absent, provisioning raises ValueError before calling the manager.
Deprovision flow
DELETE /api/services/catalog/<service_id>/accounts/<username>
AccountManager.deprovision(service_id, peer_username):
- Calls the appropriate
_deprovision_*method on the underlying manager. - Removes the peer's entry from the credentials file. If that leaves the service block empty, the service block itself is removed.
- Returns
Trueif the underlying call succeeded.
The route returns HTTP 200 with {"message": "..."} on success, or 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 and calls deprovision() for each. Failures on individual services are logged and skipped rather than aborting the deletion — the method returns {service_id: bool} for every service attempted.
PIC admin API endpoints for account management
These endpoints are in api/routes/services.py and api/routes/peers.py.
| Method | Path | Description |
|---|---|---|
GET |
/api/services/catalog/<service_id>/accounts |
Return {"service_id": "...", "accounts": ["alice", "bob"]} — reads directly from the credentials file. |
POST |
/api/services/catalog/<service_id>/accounts |
Provision a peer account. Body: {"username": "...", "password": "..."} (password optional). Returns HTTP 201 with {"service_id", "username", "provisioned": true}. |
DELETE |
/api/services/catalog/<service_id>/accounts/<username> |
Deprovision the peer's account. Returns HTTP 200 on success, HTTP 400 if the service or username is unknown. |
GET |
/api/services/catalog/<service_id>/accounts/<username>/credentials |
Return stored credentials for one peer+service pair. Returns HTTP 404 if the peer is not provisioned on that service. Response: {"service_id", "username", "password"}. |
GET |
/api/peers/<peer_name>/service-credentials |
Return filled peer_config_template values for all services the peer is provisioned on (see below). |
Admin UI: The Email, Calendar, and Files service pages in the admin dashboard each have an Accounts tab. From there, admins can provision and deprovision peer accounts, and reveal stored credentials for a provisioned peer. This tab calls the same API endpoints listed above.
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.
The route:
- Calls
AccountManager.get_all_credentials(peer_name)→{service_id: {field: value}}. - For each service, calls
ServiceRegistry.get_peer_service_info(service_id, peer_name, domain, cred). get_peer_service_infoiterates overpeer_config_templateand replaces tokens:{domain}→ effective cell domain{peer.username}→ URL-percent-encoded peer username (safe=''){peer.service_credentials.<service_id>.<field>}→ the value from stored credentials{config.<key>}→ value from the service's merged config schema
- Returns the filled template dict as the value for that service in the response.
Response shape:
{
"peer": "alice",
"services": {
"email": {
"imap_host": "mail.alice.pic.ngo",
"username": "alice@alice.pic.ngo",
"password": "<stored>"
},
"files": {
"url": "https://files.alice.pic.ngo/dav/alice/",
"username": "alice",
"password": "<stored>"
}
}
}
If a service has no peer_config_template in its manifest, get_peer_service_info returns None and the raw credential dict is used as the fallback.
Container lifecycle routes
The following PIC API endpoints are available for all services (builtins and store services). These are called by the web UI and can be called directly from the PIC admin API.
| Method | Path | Description |
|---|---|---|
GET |
/api/services/catalog/<id>/status |
Return container status. Builtins query the main compose stack; store services query their own compose project. Response includes a containers array with one entry per container. |
POST |
/api/services/catalog/<id>/restart |
Restart the service containers. Builtins restart via the main compose stack; store services restart via their 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). Store services only — builtins are reconfigured through their own settings routes. The request body must include a compose_template field containing the new template content. |
Store service HTTP API (planned)
When accounts.manager is "http", PIC will call your container's HTTP API for account operations. This path is not yet implemented in AccountManager; the dispatch table only covers email_manager, calendar_manager, and file_manager. The interface below is the planned contract — implement it now so your service is ready when HTTP dispatch is wired up.
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. Return HTTP 404 with {"ok": false, "error": "not found"} if the account does not exist.
List accounts
GET /service-api/accounts
Return {"accounts": ["alice", "bob"]} — an array of all provisioned usernames.
5. Backup integration
Declare has_storage: true in capabilities and fill in the backup block. PIC's 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 — which would require compose changes per service — 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
Each object in the volumes array describes one directory to capture:
| Field | Description |
|---|---|
container |
Name of the running container to exec into (e.g. "cell-notes"). |
path |
Absolute path inside that container to archive (e.g. "/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 before the container name) to prevent option injection. The container name is also validated against ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$ before the command is run.
config_paths
Paths in config_paths are relative to the PIC project root on the host and are copied directly into the snapshot (no docker exec). Use this for configuration files the service reads at startup, not for user data.
Full example
"backup": {
"volumes": [
{"container": "cell-notes", "path": "/data/notes", "name": "notes_data"}
],
"config_paths": ["config/notes"]
}
This produces one archive notes_data.tar.gz (streamed from the cell-notes container) plus a direct copy of config/notes/ from the host.
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 same path used during backup. Archive entries are relative paths (the backup uses tar -C <path> -czf - .), so files land in exactly the location declared in the manifest volumes entry. The target container must be running at restore time.
6. Egress routing
When has_egress is true, the cell admin can assign a specific outbound interface to your service. PIC enforces the selection using fwmark rules and policy routing in the cell-wireguard container via the ConnectivityManager.
The valid values for egress.allowed and what they mean:
| Value | Path |
|---|---|
cell_internet |
Default route through the cell's WAN interface (no VPN). |
openvpn |
Traffic leaves through tun0 (fwmark 0x20, table 120). |
wireguard |
Traffic leaves through a second WireGuard interface wg_ext0 (fwmark 0x10, table 110). |
tor |
Traffic is redirected to the Tor transparent proxy on port 9040 (fwmark 0x30, table 130). |
pic_cell |
Traffic routes through a connected peer cell via a site-to-site WireGuard link. |
List only the interfaces that make sense for your service in allowed. Listing all five is fine if your service is general-purpose. The default value is used when the admin has not changed anything. Always include cell_internet in allowed so the admin has a way to use the normal path.
The egress field in the manifest tells PIC what options to present in the UI. Actual enforcement requires the cell to have the corresponding exit type configured (an OpenVPN config uploaded, a WireGuard external config active, etc.). If the chosen exit is not active, packets will be dropped by the kill-switch FORWARD rule in cell-wireguard.
7. Quick-start example
This section walks through 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:latest",
"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).
- Allocates a static IP from the service pool (
172.20.0.20–172.20.0.254). - Writes a Docker Compose override 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 is that any WireGuard peer can reach https://home.alice.pic.ngo/ immediately after installation.
8. Submitting to the store
Package format
A store service package is a ZIP archive containing:
homepage-1.0.0.zip
├── manifest.json (required)
├── compose-template.yml (recommended for multi-container services)
└── install.sh (optional post-install script)
install.sh is executed on the cell host after the container starts. Keep it minimal — initialise data structures, create default config files. Do not use it to install system packages or modify files outside the PIC project root.
Store index entry
The store index at https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json is a JSON array. Each entry looks like:
{
"id": "homepage",
"name": "Homepage",
"description": "A static homepage served from your cell",
"version": "1.0.0",
"author": "acme"
}
PIC fetches the full manifest from https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json when the admin clicks install.
Submission process
- Fork
https://git.pic.ngo/roof/pic-services. - Create a directory
services/<your-id>/and add yourmanifest.json. - Open a pull request against
main.
The review checks the following before merging:
Security
- Image hosted on
git.pic.ngo/roof/*. No external registries. - No volume mounts to system paths or to the PIC project root.
iptables_rulesonly declareACCEPTrules (no DROP, no REJECT, no chain redirects).envvalues contain only alphanumeric characters and a small set of safe punctuation.install.shdoes not callapt,yum,curl | bash, or modify files outside the project.
Correctness
subdomaindoes not collide with the reserved list or with any existing store service.backendpoints to the declaredcontainer_name.- If
has_accounts: true, the container responds correctly on all three/service-api/accountsendpoints. - If
has_storage: true, everyvolumesentry names a container that is running and a path that exists inside it.
Quality
descriptionis one sentence, no marketing language.versionis a valid semver string.config_schemalabels are in plain English, sentence case.
Versioning
Increment version in manifest.json with every change you submit. PIC does not auto-update installed services; the admin manually runs an update. When an update is available, the UI shows the version mismatch between the installed record and the store index.
Appendix: manifest field quick reference
| Field | Builtin | Store | Notes |
|---|---|---|---|
schema_version |
required | required | Must be 3 |
id |
required | required | |
name |
required | required | |
description |
required | required | |
version |
required | required | |
author |
required | required | |
kind |
required | required | "builtin" or "store" |
min_pic_version |
optional | optional | |
capabilities.* |
required | required | All six flags must be present |
subdomain |
if has_subdomain |
if has_subdomain |
|
extra_subdomains |
optional | optional | |
backend |
if has_subdomain |
if has_subdomain |
|
extra_backends |
optional | optional | |
containers |
optional | optional | Informational |
config_schema |
if has_admin_config |
if has_admin_config |
|
peer_config_template |
if has_accounts |
if has_accounts |
|
accounts |
if has_accounts |
if has_accounts |
|
compose |
null |
null |
|
backup |
if has_storage |
if has_storage |
|
egress |
if has_egress |
if has_egress |
|
storage |
if has_storage |
if has_storage |
|
image |
not used | required | |
container_name |
not used | required | |
volumes |
not used | optional | |
env |
not used | optional | |
iptables_rules |
not used | optional | |
caddy_route |
not used | optional |