Files
archy/docs/app-developer-guide.md
T
archipelagoandClaude Opus 5 7adc3260a6 docs(app-manifest-spec): fix the id rule, the Quadlet claim, and the declarative overstatement
Narrative pass over the manifest spec, plus one correction to the guide I
committed in ba052736.

- **`app.id` allowed `_`.** `is_valid_app_id` accepts lowercase ASCII letters,
  digits and single hyphens only — no underscores, no leading/trailing hyphen,
  no `--`. The spec's "alphanumeric + `-`/`_`" would have a developer write an id
  that fails to parse.
- **"Must match the directory name"** is a convention, not a rule. The loader
  (`prod_orchestrator.rs:1455-1474`) walks `*/manifest.yml` and keys off
  `app.id`, never comparing it to the folder, so a mismatch silently registers
  the app under a different id. Said so rather than implying enforcement.
- **The Quadlet claim was the same one architecture.md was corrected for**
  (f55ed6bf): install does NOT compile to a `user.slice` Quadlet unit today.
  `config.use_quadlet_backends` defaults false, so apps take the legacy
  `podman create + start` path; Quadlet is opt-in per node and companion UIs are
  the exception that already use it.
- **"no per-app installer code"** — true of installers, but
  `run_pre_start_hooks` is a hardcoded `match app_id` covering seven first-party
  apps (bitcoin-ui, filebrowser, lnd, archy-nbxplorer, btcpay-server,
  fedimint-clientd, grafana). Documented as the caveat it is; anyone reading the
  source will find it in a minute and the doc should not look like it's hiding it.
- `derived_env` now names the full closed allow-list including `{{BITCOIN_HOST}}`
  and what it resolves to.

Correction to ba052736: I wrote there that an unknown `derived_env` placeholder
passes through verbatim. It doesn't — `validate_derived_template` rejects both
unknown names and unbalanced `{{`. Fixed that row in the guide.

Verified accurate and left alone: the capability allow-list, network_policy
values, `/dev/*` device rule, volume option allow-list, bind-source confinement,
the four generated_secret kinds, `hooks.pre_start` being schema-only, and the
30s reconciler interval (`BootReconciler::DEFAULT_INTERVAL`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:49:17 -04:00

18 KiB

Archipelago App Developer Guide

Build and package containerized apps for Archipelago.

Overview

Apps run as rootless Podman containers on user nodes. You describe an app in apps/<app-id>/manifest.yml; the backend validates that manifest, compiles it into rootless container/runtime behavior, and the release pipeline generates catalog surfaces from the same manifest-owned metadata.

Archipelago's app contract is deliberately manifest-first. A developer should be able to describe images or local builds, ports, volumes, generated files, dependencies, health/readiness, data ownership, networking, secrets, and supported bridge integrations in the app manifest without asking for a custom OS image or app-specific backend patch. When a real app needs a capability that is not represented yet, the preferred path is to add a reusable manifest/orchestrator primitive that other apps can use too.

The historical marketplace-publish design is not the active local developer contract for 1.8-alpha. For this release, local manifests are the source of truth and catalog JSON is generated from them.

App Manifest

Every app needs a manifest at apps/<app-id>/manifest.yml. The root key is app; runtime, catalog, and integration fields live below that key.

Template Manifest

# apps/my-app/manifest.yml
app:
  id: my-app                    # Unique, lowercase kebab-case
  name: My App
  version: 1.0.0                # Semantic versioning
  description: My App does one thing well.

  container:
    image: docker.io/myorg/my-app:1.0.0
    pull_policy: if-not-present
    network: archy-net
    entrypoint: ["sh", "-lc"]
    custom_args:
      - /app/start.sh
    derived_env:
      - key: PUBLIC_URL
        template: https://{{HOST_MDNS}}:8180
    secret_env:
      - key: APP_PASSWORD
        secret_file: my-app-password

  dependencies:
    - storage: 1Gi

  resources:
    cpu_limit: 2
    memory_limit: 512Mi

  security:
    capabilities: []
    readonly_root: true
    no_new_privileges: true
    network_policy: isolated

  ports:
    - host: 8180
      container: 8080
      protocol: tcp

  volumes:
    - type: bind
      source: /var/lib/archipelago/my-app
      target: /data
      options: [rw]

  environment:
    - APP_MODE=production

  health_check:
    type: http
    endpoint: http://localhost:8080
    path: /health
    interval: 30s
    timeout: 5s
    retries: 3

  files:
    - path: /var/lib/archipelago/my-app/config.yml
      content: |
        bind: 0.0.0.0:8080
      overwrite: false

  metadata:
    icon: /assets/img/app-icons/my-app.svg
    category: tools
    tier: optional
    repo: https://github.com/myorg/my-app
    launch:
      open_in_new_tab: false

Required Fields

Field Description
app.id Unique identifier, lowercase, kebab-case only
app.name Human-readable name
app.version Version string containing at least one digit; semantic versions are preferred
container.image or container.build Exactly one image source must be present
security.readonly_root Should remain true for normal apps
security.no_new_privileges Should remain true for normal apps

Current Manifest Fields

Field Purpose
app.id, app.name, app.version, app.description App identity and release metadata
app.container.image Registry image to pull
app.container.build Local build definition with context, dockerfile, tag, and optional build_args
app.container.pull_policy Pull behavior, usually if-not-present
app.container.network Podman network setting such as archy-net or pasta; dangerous namespace-sharing modes are rejected
app.container.entrypoint / custom_args Entrypoint and command override
app.container.derived_env Environment values rendered from host facts. The complete placeholder set is {{HOST_IP}}, {{HOST_MDNS}}, {{DISK_GB}}, {{BITCOIN_HOST}}; an unknown name or an unbalanced {{ is a parse error, so typos fail loudly
app.container.secret_env Environment values read from /var/lib/archipelago/secrets/<secret_file>, injected as podman secrets (never visible in podman inspect or unit files)
app.container.generated_secrets Secrets the orchestrator creates on first use (hex16/hex32/base64/bcrypt) — self-healing, 0600, no host provisioning
app.container.generated_certs Self-signed TLS certs materialised before create; CN/SANs rendered from host facts
app.container.network_aliases Extra DNS names on the app network so stack members answer to short baked-in hostnames (api, minio, relay)
app.container.data_uid UID:GID ownership repair for app data directories
app.hooks Allow-listed lifecycle hooks (post_install: exec inside the app's own container, copy_from_host from allow-listed roots) — see manifest-hooks-design.md
app.dependencies Storage requirements and app dependencies
app.resources CPU, memory, and disk limits
app.security Capabilities, read-only root, no-new-privileges, network policy, optional AppArmor profile
app.ports Host-to-container port mappings
app.volumes bind, volume, or tmpfs mounts
app.files Generated files under declared bind-mounted host paths
app.environment Static KEY=value environment entries
app.health_check HTTP or TCP health check settings
app.devices Explicit device paths
app.metadata Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints
app.interfaces.main Optional primary UI launch surface with port, protocol, and path

Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.

Use metadata.launch.open_in_new_tab: true when the app UI is known to reject iframe embedding with headers such as X-Frame-Options or restrictive CSP. The frontend app-session metadata is generated from this flag during release work.

Launch Interfaces

If an app exposes a user-facing web UI, declare its primary launch surface in interfaces.main. Runtime package listings prefer this interface over inferred port mappings, which matters for apps that expose non-UI service ports or use a companion wait/proxy UI.

interfaces:
  main:
    name: Web UI
    description: Primary app interface
    type: ui
    port: 8180
    protocol: http
    path: /

For simple HTTP apps without interfaces.main, Archipelago can still infer the launch URL from the first declared TCP host port when the app has an HTTP health check. TCP-only service ports, such as Bitcoin RPC/P2P, are not treated as UI launch URLs.

Interface keys must use lowercase ASCII letters, digits, hyphens, or underscores. Supported interface types are ui, api, and metrics; only type: ui is treated as a launchable app surface. Supported protocols are http and https, and path must start with /.

Nostr Signer Bridge (NIP-07)

Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign events without managing their own keys. Archipelago injects a NIP-07 provider (window.nostr with getPublicKey() / signEvent() / nip04 / nip44) that bridges to the host. Your app code uses standard NIP-07 — no Archipelago-specific API.

How injection works. After install, the host copies nostr-provider.js into the app container and patches the app's web server so every page loads it and the app is iframe-embeddable. This is best-effort and depends on your server config exposing the right hooks. For an nginx-served SPA (the supported reference shape, e.g. IndeeHub) your nginx.conf must satisfy this contract:

  1. Be iframe-embeddable. Do not send a hard X-Frame-Options: DENY. The host strips a SAMEORIGIN/DENY X-Frame-Options header line if present; restrictive CSP frame-ancestors will still block embedding.
  2. Keep an exact-match location = /sw.js { block. The provider's no-cache location = /nostr-provider.js block is inserted immediately before it.
  3. Keep an SPA fallback line try_files $uri $uri/ /index.html;. A sub_filter that injects <script src="/nostr-provider.js"></script> before </head> is inserted right after it. (nginx must have ngx_http_sub_module — stock nginx:alpine does.)
  4. If you proxy an API that does NIP-98 URL verification, expose proxy_set_header X-Forwarded-Prefix /api;; the host rewrites it to honor the outer reverse proxy's prefix.

The patch is idempotent (it checks for an existing nostr-provider reference before editing) and re-runs on reinstall. If you rename or remove any of the anchor strings above, injection silently no-ops and window.nostr will be undefined in your app — so guard those lines in your config (see the contract comment block at the top of IndeeHub's nginx.conf for a template).

Non-nginx servers (Next.js node server.js, etc.) are not auto-patched today. Either serve via nginx, or ship nostr-provider.js yourself and reference it in your HTML; the canonical script lives at /opt/archipelago/web-ui/nostr-provider.js on the node.

Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):

metadata:
  launch:
    open_in_new_tab: false   # default; set true only if the app cannot be iframed

Security Requirements

Two different things enforce these, and it's worth knowing which is which:

  • The Rust parser (core/container/src/manifest.rs) is the hard gate. A manifest that violates one of its rules fails to parse, so the app cannot be installed at all.
  • scripts/validate-app-manifest.sh is the submission preflight. It applies the policy rules the parser doesn't encode, and grades them fail/warn.

Mandatory

  1. No :latest tag — Pin a specific version: myapp:1.0.0. Checked by the preflight script (a fail for new apps, a warn for existing manifests being migrated), not by the parser — a :latest manifest still installs, so pinning is on you.
  2. Read-only root filesystemsecurity.readonly_root: true (use volumes for writable data). This is the parser's default when you omit it.
  3. No privilege escalationsecurity.no_new_privileges: true. Also the parser's default when omitted.
  4. Minimal capabilities — Drop all caps, only add required ones. The allow-list below is parser-enforced: anything outside it is a parse error.
  5. No host network unless explicitly approved — keep security.network_policy isolated or bridge (isolated is the default).

Allowed Capabilities

The parser currently accepts this allow-list. Keep capability requests minimal; some accepted capabilities still require release review before a public package should depend on them.

Capability When Needed
CHOWN App needs to change file ownership
DAC_OVERRIDE App needs to bypass file permissions
FOWNER App needs ownership-related file operations
NET_ADMIN Network administration; requires extra scrutiny
NET_BIND_SERVICE App binds to ports below 1024
NET_RAW Raw network sockets; requires extra scrutiny
SETUID, SETGID App manages user switching
SYS_ADMIN Broad administrative capability; avoid for normal apps

Forbidden

  • Namespace-sharing network modes such as container:<name> or ns:<path>
  • Host bind mounts outside /var/lib/archipelago/. This is an allow-list, not a blocklist of "system paths": volumes[].source must be absolute and start with /var/lib/archipelago/, or be a plain named volume (no slashes), or be one of two reviewed exceptions (/run/user/1000/podman/podman.sock, /var/run/dbus). .. anywhere in the path is rejected. Everything else fails to parse, so your app's data belongs under /var/lib/archipelago/<app-id>
  • Capabilities outside the allow-list above — including SYS_PTRACE
  • Privileged containers or rootful execution
  • Hardcoded secrets in environment variables or images — use secret_env or generated_secrets

Container Best Practices

Volumes

volumes:
  - type: bind
    source: /var/lib/archipelago/my-app
    target: /data
    options: [rw]

Data is stored at /var/lib/archipelago/{app-id}/ on the host.

Generated files must live under a declared bind-mounted host path:

files:
  - path: /var/lib/archipelago/my-app/config.yml
    content: |
      bind: 0.0.0.0:8080
    overwrite: false

Use overwrite: false for first-run defaults that users or the app may later modify. Use overwrite: true only for generated files the platform must own.

files[].content supports its own placeholder set — a different one from derived_env:

Placeholder Renders to
{{HOST_IP}} / {{HOST_MDNS}} Host facts (hostname -I / the node's .local name)
{{NETWORK_GATEWAY}} The gateway of the app's Podman network, i.e. aardvark's DNS address. Use it as an nginx resolver so container names re-resolve per request instead of pinning a stale IP and 502-ing after a restart
{{secret:NAME}} The trimmed contents of the 0600 secret NAME from the service-owned secrets dir. NAME must be a bare filename. Never logged

Health Checks

Define a health check endpoint in your container:

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

Logging

  • Log to stdout/stderr (Podman captures container logs)
  • Never log secrets, passwords, or keys
  • Use structured logging (JSON) for machine parsing

Networking

Apps get their own network namespace. To connect to other Archipelago apps:

# If your app needs to talk to Bitcoin
dependencies:
  - bitcoin-knots

container:
  network: archy-net
  derived_env:
    - key: BITCOIN_RPC_HOST
      template: "{{BITCOIN_HOST}}"   # resolves to whichever Bitcoin app is installed
    - key: BITCOIN_RPC_PORT
      template: "8332"

Prefer {{BITCOIN_HOST}} over hardcoding bitcoin-knots — a node may be running Bitcoin Core instead, and the placeholder resolves to whichever is present.

The archy-net Podman network provides DNS resolution between containers. Use derived_env for host facts like HOST_MDNS instead of hardcoding node-specific URLs.

Catalog Generation

Catalog JSON is generated from manifests during release work. Do not manually edit generated fields in app-catalog/catalog.json or neode-ui/public/catalog.json when the same value belongs in the manifest.

Manifest-owned catalog fields currently include:

  • app title from app.name;
  • version from app.version;
  • description from app.description;
  • Docker image from app.container.image;
  • category from app.category or app.metadata.category;
  • tier from app.metadata.tier;
  • icon from app.metadata.icon;
  • repo URL from app.metadata.repo, repoUrl, or source.

1. Build and Push Your Image

podman build -t docker.io/myorg/my-app:1.0.0 .
podman push docker.io/myorg/my-app:1.0.0

2. Generate Catalogs

python3 scripts/generate-app-catalog.py

3. Verify Drift

python3 scripts/check-app-catalog-drift.py --release --strict

Before release, the canonical catalog and UI public catalog should match:

cmp -s app-catalog/catalog.json neode-ui/public/catalog.json

Testing Your App

Validate Your Manifest

Before anything else, check your manifest against the schema and the app-submission rules:

./scripts/validate-app-manifest.sh apps/my-app/manifest.yml

It reports STATUS: APPROVED or STATUS: REJECTED with the specific failures — including the ones that block submission, such as an unpinned :latest image tag (new apps must pin a concrete version). It needs python3 and PyYAML; it tells you if either is missing. The Rust parser in core/container/src/manifest.rs remains the canonical validator — this script is the fast local preflight.

Local Testing

# Run your container locally
podman run -d --name my-app \
  -p 8180:8080 \
  --read-only \
  --security-opt no-new-privileges \
  --user 1000:1000 \
  docker.io/myorg/my-app:1.0.0

# Verify it works
curl http://localhost:8180/health

# Check logs
podman logs my-app

On an Archipelago Node

  1. Install via the marketplace UI or RPC:
    curl -b cookies.txt -X POST http://archipelago.local/rpc/v1 \
      -d '{"method":"package.install","params":{"id":"my-app","dockerImage":"docker.io/myorg/my-app:1.0.0"}}'
    
  2. Verify the container is running:
    curl -b cookies.txt -X POST http://archipelago.local/rpc/v1 \
      -d '{"method":"container-list"}'
    
  3. Check the UI. The app's detail page is http://archipelago.local/dashboard/apps/my-app; the embedded launch surface is http://archipelago.local/dashboard/app-session/my-app

Validate Manifest

cargo test --manifest-path core/Cargo.toml -p archipelago-container
python3 scripts/check-app-catalog-drift.py --release --strict

Updating Your App

  1. Build and push the new version: docker.io/myorg/my-app:1.1.0.
  2. Update app.version and app.container.image or app.container.build.tag.
  3. Run catalog generation and drift checks.
  4. Validate install/start/stop/restart/uninstall/reinstall behavior before shipping.

The broader app update policy for 1.8-alpha is still being finalized. Until that policy is locked, app manifests should be explicit and pinned so update detection compares concrete image/tag metadata rather than mutable tags.

App Icon

  • Provide a URL to your app icon (PNG, WebP, or SVG)
  • Recommended size: 256x256 pixels
  • Square aspect ratio
  • If no icon URL, a generic placeholder is shown in the marketplace

Release Validation Expectations

Every supported app must satisfy the lifecycle contract:

  • install
  • launch
  • stop
  • start
  • restart
  • uninstall while preserving data
  • reinstall with preserved data
  • report truthful health/status
  • survive backend restart
  • survive host reboot

For apps with special dependencies, launch must explain dependency wait states instead of showing a dead iframe. Examples include Bitcoin sync/IBD, Lightning wallet readiness, Nostr signer bridge injection, Tailscale login/auth, and app-specific setup screens.

Runtime changes should be validated with focused tests first, then the release lifecycle harness on the validation host when host access is intentionally resumed.