Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit b33b9af85a
1963 changed files with 449763 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Archipelago Apps — Development Guide
## App Overview
### Bitcoin & Lightning
| App | Ports | Version |
|-----|-------|---------|
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
### Nostr
| App | Ports | Version |
|-----|-------|---------|
| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 |
| nostrudel | 8082 (HTTP) | v0.40.0 |
### Self-Hosted
| App | Port | Version |
|-----|------|---------|
| nextcloud | 8084 | v28 |
| jellyfin | 8096 | v10.8.13 |
| immich | 2283 | release |
| photoprism | 2342 | v240915 |
| vaultwarden | 8222 | v1.30.0-alpine |
| homeassistant | 8123 | v2024.1 |
| filebrowser | 8083 | v2.27.0 |
| searxng | 8888 | 2024.11.17 |
| ollama | 11434 | v0.5.4 |
| grafana | 3001 | v10.2.0 |
| portainer | 9000 | v2.19.4 |
| penpot | 8089 | v2.4 |
## Building Apps
```bash
cd apps
./build.sh # Build all custom apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure
Each app directory contains:
- `manifest.yml` — Container configuration
- `Dockerfile` — Image definition (custom apps only)
- `README.md` — App-specific docs (custom apps only)
- `src/` — Source code (custom apps only)
## Running in Development
The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC:
```bash
curl -X POST http://localhost:5959/rpc/v1 \
-H "Content-Type: application/json" \
-d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}'
```
### Manual Testing (Podman)
```bash
# Build
./build.sh router
# Run directly with Podman
podman run -p 18084:8080 \
-v /tmp/archipelago-dev/router:/app/data \
localhost/archipelago/router:latest
```
## Integration Checklist
Adding a new app requires updates in multiple places:
- add `apps/<app-id>/manifest.yml`;
- add a Dockerfile and source directory only when the app is built locally;
- choose non-conflicting ports from [PORTS.md](./PORTS.md);
- declare `interfaces.main` for user-facing web UIs;
- declare generated secrets instead of hardcoding credentials;
- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`;
- regenerate catalogs with `python3 scripts/generate-app-catalog.py`;
- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`;
- test install, launch, stop, start, restart, uninstall, and reinstall.
## Port Assignments
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
+85
View File
@@ -0,0 +1,85 @@
# Port Assignments Reference
This document lists all port assignments for Archipelago apps.
## Production Ports
| App | Port(s) | Protocol | Service | Dev Port(s) |
|-----|---------|----------|---------|-------------|
| bitcoin-core | 8332, 8333 | TCP | RPC, P2P | 18332, 18333 |
| btcpay-server | 80, 443 | TCP | HTTP, HTTPS | 10080, 10443 |
| home-assistant | 8123 | TCP | Web UI | 18123 |
| grafana | 3001 | TCP | Web UI | 13001 |
| endurain | 8085 | TCP | Web UI | 18085 |
| fedimint | 8173, 8174 | TCP | API, Web UI | 18173, 18174 |
| morphos-server | 8086 | TCP | Web UI | 18086 |
| lightning-stack | 9737, 10010, 8087 | TCP | P2P, gRPC, REST | 19737, 20010, 18087 |
| mempool | 4080 | TCP | Web UI | 14080 |
| ollama | 11434 | TCP | API | 21434 |
| searxng | 8888 | TCP | Web UI | 18888 |
| penpot | 8089 | TCP | Web UI | 18089 |
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
| podsteadr | 8095, 1935, 8889, 8189, 8890, 8098 | TCP/UDP | Web UI/API/RSS, RTMP ingest, WebRTC/WHIP ingest, WebRTC ICE (udp), HLS playback, Blossom blobs | 18095, 11935, 18889, 18189, 18890, 18098 |
## Development Ports (Offset: +10000)
In development mode, all ports are offset by 10000 to avoid conflicts with production services.
### Quick Access URLs (Development)
| App | Dev URL |
|-----|----------|
| Bitcoin Core RPC | http://localhost:18332 |
| BTCPay Server | http://localhost:10080 |
| Home Assistant | http://localhost:18123 |
| Grafana | http://localhost:13001 |
| Endurain | http://localhost:18085 |
| Fedimint | http://localhost:18174 |
| MorphOS Server | http://localhost:18086 |
| Lightning Stack | http://localhost:18087 |
| Mempool | http://localhost:14080 |
| Ollama | http://localhost:21434 |
| SearXNG | http://localhost:18888 |
| Penpot | http://localhost:18089 |
| LND REST | http://localhost:18080 |
| Core Lightning | http://localhost:19835 |
| Nostr RS Relay | http://localhost:18081 |
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution
All apps use unique base ports to prevent conflicts. The port offset system ensures:
- No conflicts in production (each app has unique ports)
- No conflicts in development (offset applied automatically)
- Easy port management via PortManager
## Changing Port Offset
The port offset is configurable via environment variable:
```bash
ARCHIPELAGO_PORT_OFFSET=10000
```
Or in the Archipelago config:
```toml
[dev]
port_offset = 10000
```
## Port Ranges
- **Bitcoin/Lightning**: 8000-10000 range
- **Web Services**: 3000-9000 range
- **System Services**: 10000+ range
- **Custom Apps**: 8000-9000 range
+107
View File
@@ -0,0 +1,107 @@
# Quick Start Guide - Archipelago Apps
This guide will help you get all prepackaged apps running in your development environment.
## Prerequisites
1. **Container Runtime**: Podman or Docker
```bash
# Check if available
podman --version # or docker --version
```
2. **Node.js** (for custom apps): v18+
```bash
node --version
```
3. **Archipelago Backend**: Running in dev mode
```bash
cd core
ARCHIPELAGO_DEV_MODE=true cargo run --bin archipelago
```
## Building Apps
### Build All Apps
```bash
cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
```
## Running Apps via Archipelago
Once the backend is running, you can install and start apps via:
1. **UI**: Navigate to http://localhost:8100 and use the Apps/Marketplace interface
2. **RPC**: Use the container-install RPC method
```bash
curl -X POST http://localhost:5959/rpc/v1 \
-H "Content-Type: application/json" \
-d '{
"method": "container-install",
"params": {
"manifest_path": "apps/router/manifest.yml"
}
}'
```
## Port Access
In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:
```bash
./build.sh <app-id>
```
3. **Restart** the container via Archipelago UI or RPC
### For Standard Apps
Standard apps use official images. To customize:
1. Create a custom Dockerfile that extends the official image
2. Add your customizations
3. Update the manifest to use your custom image
## Testing Locally
You can test apps directly without Archipelago:
```bash
# Build
./build.sh router
# Run
docker run -p 18084:8080 \
-v /tmp/archipelago-dev/router:/app/data \
archipelago/router:latest
```
## Next Steps
- Read [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed development information
- Check [PORTS.md](./PORTS.md) for port assignments
- Review individual app READMEs for app-specific details
+45
View File
@@ -0,0 +1,45 @@
# Archipelago App Manifests
Containerized applications for the Archipelago Bitcoin Node OS. All apps run in rootless Podman with security hardening (cap-drop ALL, readonly root, non-root user, memory limits).
## App Categories
### Bitcoin & Lightning
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
### Nostr
- **nostr-rs-relay** — High-performance Rust relay (v0.9.0)
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **did-wallet** — Web5 DID Wallet
### Self-Hosted Services
- **podsteadr** — Nostr-native podcast publishing and livestreaming (RTMP/WebRTC ingest, HLS, RSS, Blossom media)
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
### Networking
- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1)
### Custom & External
- **indeedhub** — Bitcoin documentary streaming (custom build)
- **router** — Mesh routing and network management
- **botfights** — External web app
## Manifest Format
Each app has a `manifest.yml` defining container image, resources, dependencies, security policies, health checks, and network config. See [`docs/app-manifest-spec.md`](../docs/app-manifest-spec.md) for the spec.
## Quick Reference
- [PORTS.md](./PORTS.md) — Complete port mapping
- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow
+39
View File
@@ -0,0 +1,39 @@
app:
id: aiui
name: AI Assistant
version: 0.1.0
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
container:
image: localhost/archipelago-aiui:latest
pull_policy: always
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated # No outbound network — all data comes via context broker
apparmor_profile: aiui
ports:
- host: 5180
container: 80
protocol: tcp
bind: 127.0.0.1 # Only accessible via nginx proxy, not externally
auth: local
health_check:
type: http
endpoint: http://localhost:80
path: /
interval: 60s
timeout: 5s
retries: 3
+49
View File
@@ -0,0 +1,49 @@
app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
description: Postgres backend for BTCPay and NBXplorer.
container:
image: 146.59.87.168:3000/lfg2025/postgres:15.17
pull_policy: if-not-present
network: archy-net
data_uid: "100998:100998"
secret_env:
- key: POSTGRES_PASSWORD
secret_file: btcpay-db-password
dependencies:
- storage: 20Gi
resources:
memory_limit: 1Gi
disk_limit: 20Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/postgres-btcpay
target: /var/lib/postgresql/data
options: [rw]
environment:
- POSTGRES_DB=btcpay
- POSTGRES_USER=btcpay
health_check:
type: tcp
endpoint: localhost:5432
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+51
View File
@@ -0,0 +1,51 @@
app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
description: MariaDB backend for the mempool explorer stack.
container:
image: 146.59.87.168:3000/lfg2025/mariadb:11.4.10
pull_policy: if-not-present
network: archy-net
data_uid: "100998:100998"
secret_env:
- key: MYSQL_PASSWORD
secret_file: mempool-db-password
- key: MYSQL_ROOT_PASSWORD
secret_file: mysql-root-db-password
dependencies:
- storage: 20Gi
resources:
memory_limit: 512Mi
disk_limit: 20Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/mysql-mempool
target: /var/lib/mysql
options: [rw]
environment:
- MYSQL_DATABASE=mempool
- MYSQL_USER=mempool
health_check:
type: tcp
endpoint: localhost:3306
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+49
View File
@@ -0,0 +1,49 @@
app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
pull_policy: if-not-present
network: archy-net
dependencies:
- app_id: mempool-api
version: ">=3.0.0"
resources:
memory_limit: 512Mi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 4080
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
environment:
- FRONTEND_HTTP_PORT=8080
- BACKEND_MAINNET_HTTP_HOST=mempool-api
health_check:
type: http
# 127.0.0.1 not localhost: the image's wget resolves localhost to ::1 (IPv6)
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+66
View File
@@ -0,0 +1,66 @@
app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
description: BTCPay blockchain indexer service.
container:
image: 146.59.87.168:3000/lfg2025/nbxplorer:2.6.0
pull_policy: if-not-present
network: archy-net
secret_env:
- key: NBXPLORER_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: archy-btcpay-db
version: ">=15.17"
resources:
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 32838
container: 32838
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/nbxplorer
target: /data
options: [rw]
environment:
- NBXPLORER_DATADIR=/data
- NBXPLORER_NETWORK=mainnet
- NBXPLORER_CHAINS=btc
- NBXPLORER_BIND=0.0.0.0:32838
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
- NBXPLORER_BTCRPCUSER=archipelago
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
- NBXPLORER_NOAUTH=1
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
health_check:
type: http
endpoint: http://localhost:32838
path: /
interval: 30s
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
sync_required: true
+32
View File
@@ -0,0 +1,32 @@
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
# No official upstream image exists (their GitLab registry is empty), so we
# package the pinned, checksum-verified release binary ourselves and push to
# the node registry — same approach as fmcd. Keep the version in lockstep with
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
FROM debian:bookworm-slim
ARG BARKD_VERSION=0.3.0
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
-o /usr/local/bin/barkd && \
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
chmod a+x /usr/local/bin/barkd && \
apt-get purge -y curl && apt-get autoremove -y && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod a+x /entrypoint.sh
# The wallet itself is created over REST by the node's Ark bridge
# (wallet.ark-* RPCs) — the container just runs the daemon.
ENV BARKD_DATADIR=/data \
BARKD_BIND_HOST=0.0.0.0 \
BARKD_BIND_PORT=3535
EXPOSE 3535
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Install the node-provided auth secret (64-char hex from the manifest's
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
# random token in the datadir and the bridge won't authenticate — so treat a
# failed refresh as fatal rather than starting an unreachable daemon.
set -eu
if [ -n "${BARKD_SECRET:-}" ]; then
# `secret refresh` prints the Bearer token on stdout — never log it.
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
unset BARKD_SECRET
fi
exec barkd
+78
View File
@@ -0,0 +1,78 @@
app:
id: barkd
name: Ark Wallet
version: 0.3.0
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
# barkd packaged from the pinned upstream release binary (no usable
# upstream image exists — their registry is empty). Built from
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
# (validated against barkd 0.3.0 on signet, 2026-07-14).
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
pull_policy: if-not-present
network: archy-net
# The entrypoint installs the shared secret below via `barkd secret
# refresh` (so the wallet bridge can derive the matching Bearer token) and
# execs the daemon. The Ark wallet itself is created over REST by the
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
# (default: Second's public signet server) — no host provisioning needed.
generated_secrets:
- name: barkd-secret
kind: hex32
secret_env:
- key: BARKD_SECRET
secret_file: barkd-secret
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
# starve the node.
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
readonly_root: true
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
# esplora chain source, plus the published REST port for the wallet
# bridge. No inbound requirements beyond that.
network_policy: bridge
ports:
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
# the host (see port_allocator.rs). The Rust bridge targets
# http://127.0.0.1:3535.
- host: 3535
container: 3535
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
# on-chain from this datadir (unilateral exit) — include it in backups.
- type: bind
source: /var/lib/archipelago/barkd
target: /data
options: [rw]
environment:
- BARKD_DATADIR=/data
- BARKD_BIND_HOST=0.0.0.0
- BARKD_BIND_PORT=3535
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
# verifies reachability).
health_check:
type: tcp
endpoint: localhost:3535
interval: 30s
timeout: 5s
retries: 3
+34
View File
@@ -0,0 +1,34 @@
# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release.
#
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
# (fail-closed), and tags/pushes <registry>/bitcoin:<version>. This Dockerfile
# mirrors that image for a manual/local build and replaces the old stale
# community base (`FROM bitcoin/bitcoin:24.0`).
#
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
# scripts/build-bitcoin-image.sh core 31.0
FROM debian:bookworm-slim
ARG BITCOIN_VERSION=31.0
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
useradd -m -u 1000 -s /bin/bash bitcoin; \
mkdir -p /home/bitcoin/.bitcoin; \
chown -R bitcoin:bitcoin /home/bitcoin
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built,
# x86_64-linux-gnu) extracted from the official release tarball.
COPY bin/bitcoind /usr/local/bin/bitcoind
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
# Run as (container) root, like the legacy hand-built :latest image. Rootless
# Podman maps container-root to the unprivileged host service user; the manifest
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
# bitcoind crash-loops with "Error initializing block database".
WORKDIR /home/bitcoin
VOLUME ["/home/bitcoin/.bitcoin"]
EXPOSE 8332 8333
ENTRYPOINT ["bitcoind"]
+119
View File
@@ -0,0 +1,119 @@
app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
container:
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
#
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
# which pushed every IBD "UpdateTip" line through conmon into journald
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
# restart) — use that for deep debugging; podman logs only carries
# entrypoint/startup errors.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
- storage: 500Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 500Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
# dial the container's archy-net alias directly (bitcoin-core:8332),
# which needs no publish at all. Do NOT bind the archy-net gateway
# (10.89.0.1): rootlessport binds in the HOST netns where that address
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
# P2P 8333 stays public.
- host: 8332
container: 8332
protocol: tcp
bind: 127.0.0.1
auth: local
- host: 8333
container: 8333
protocol: tcp
auth: none
auth_rationale: >-
Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- BITCOIN_RPC_USER=archipelago
health_check:
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: false
pruning_support: true
+35
View File
@@ -0,0 +1,35 @@
# Bitcoin Knots — minimal rootless image built from the OFFICIAL upstream release.
#
# Knots previously had NO Dockerfile (the :latest tag was built/pushed by hand).
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
# (fail-closed, Luke-Jr release key), and tags/pushes
# <registry>/bitcoin-knots:<version>. Knots version strings embed a build date,
# e.g. 29.3.knots20260508 — the full string is the tag.
#
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
# scripts/build-bitcoin-image.sh knots 29.3.knots20260508
FROM debian:bookworm-slim
ARG KNOTS_VERSION=29.3.knots20260508
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
useradd -m -u 1000 -s /bin/bash bitcoin; \
mkdir -p /home/bitcoin/.bitcoin; \
chown -R bitcoin:bitcoin /home/bitcoin
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built,
# x86_64-linux-gnu) extracted from the official release tarball.
COPY bin/bitcoind /usr/local/bin/bitcoind
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
# Run as (container) root, like the legacy hand-built :latest image. Rootless
# Podman maps container-root to the unprivileged host service user; the manifest
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
# bitcoind crash-loops with "Error initializing block database".
WORKDIR /home/bitcoin
VOLUME ["/home/bitcoin/.bitcoin"]
EXPOSE 8332 8333
ENTRYPOINT ["bitcoind"]
+119
View File
@@ -0,0 +1,119 @@
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
container:
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
#
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
# which pushed every IBD "UpdateTip" line through conmon into journald
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
# restart) — use that for deep debugging; podman logs only carries
# entrypoint/startup errors.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
- storage: 500Gi
resources:
cpu_limit: 0
memory_limit: 8Gi
disk_limit: 500Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
# dial the container's archy-net alias directly (bitcoin-knots:8332),
# which needs no publish at all. Do NOT bind the archy-net gateway
# (10.89.0.1): rootlessport binds in the HOST netns where that address
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
# P2P 8333 stays public.
- host: 8332
container: 8332
protocol: tcp
bind: 127.0.0.1
auth: local
- host: 8333
container: 8333
protocol: tcp
auth: none
auth_rationale: >-
Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- BITCOIN_RPC_USER=archipelago
health_check:
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: false
pruning_support: true
+71
View File
@@ -0,0 +1,71 @@
app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
upstream Authorization header is substituted from
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
orchestrator's pre-start hook, rendered into an nginx.conf that is
bind-mounted read-only at container start.
container:
build:
context: /opt/archipelago/docker/bitcoin-ui
dockerfile: Dockerfile
tag: localhost/bitcoin-ui:local
dependencies:
- app_id: bitcoin-core
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8334 directly on the host IP, and
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
# its RPC. `ports:` is intentionally empty because host networking
# bypasses port mapping.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the Bitcoin screen unauthenticated on every interface.
ports:
- host: 8334
container: 8334
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes:
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
# and every reconcile pass, substituting the base64 RPC auth from
# the plaintext password secret. If the rendered bytes change (the
# password rotated, or the template was updated by OTA), the
# reconciler restarts this container so nginx re-reads the config.
- type: bind
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8334
path: /
interval: 30s
timeout: 5s
retries: 3
+131
View File
@@ -0,0 +1,131 @@
app:
id: botfights
name: BotFights
version: 1.2.11
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
container:
image: 146.59.87.168:3000/lfg2025/botfights:1.2.11
pull_policy: always
# Auto-generated on first install (random hex, 0600, rootless-owned). The
# 1.2.x image's server/src/middleware/jwt.ts throws at module import when
# JWT_SECRET is unset and NODE_ENV=production, so a fresh install without
# this crash-loops immediately. hex32 (not base64, unlike netbird) because
# jwt.ts uses the value directly as an HMAC key with no decode step.
generated_secrets:
- name: botfights-jwt-secret
kind: hex32
secret_env:
- key: JWT_SECRET
secret_file: botfights-jwt-secret
# Was missing entirely (found live during a fresh install on a second
# node): without it, the orchestrator's bind-dir ownership fixup only
# fires via a same-owner-as-anchor fallback that assumes an app with no
# data_uid runs as container-internal root — but this app runs as a
# non-root system user, so that fallback doesn't apply either. The bind
# mount ended up unwritable, crash-looping the container on startup
# (SqliteError: unable to open database file). Same pattern as
# apps/fedimint-clientd/manifest.yml and apps/barkd/manifest.yml.
#
# 999, not 1001: the image's Dockerfile does `useradd --system` with no
# explicit UID, which lands at 999 (confirmed via `podman exec botfights
# id` — uid=999(botfights) gid=999(botfights)), not the security.user
# value below. security.user is not currently read by the non-Quadlet
# install path this app uses (only quadlet.rs consumes
# security.{capabilities,readonly_root,no_new_privileges,network_policy}
# for companion containers) — it's descriptive metadata here, not
# enforced. A first pass at this fix used 1001 (copying the
# fedimint-clientd/barkd pattern without verifying against this image)
# and still crash-looped; corrected after inspecting the running
# container's actual UID.
data_uid: "999:999"
dependencies:
- storage: 500Mi
resources:
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 500Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 999
seccomp_profile: default
network_policy: bridge
apparmor_profile: default
ports:
- host: 9100
container: 9100
protocol: tcp # Web UI + API
bind: 127.0.0.1
auth: gated
volumes:
# A bare relative source (was "botfights-data", no leading slash) is
# inconsistent with every other app's manifest, which uses an absolute
# host path — found live during a fresh install on a second node:
# resolved to /var/lib/archipelago/botfights on archi-dev-box (by
# accident of that node's specific state) but /home/archipelago/
# botfights-data on a different node, which doesn't exist there,
# crash-looping the container on a real SqliteError: unable to open
# database file. Absolute path removes the ambiguity entirely, matching
# apps/netbird-server/manifest.yml and every other app's convention.
- type: bind
source: /var/lib/archipelago/botfights
target: /app/server/data
- type: tmpfs
target: /tmp
options: [rw,noexec,nosuid,size=64m]
environment:
- NODE_ENV=production
- PORT=9100
# Default-on shared public arena federation (BOT-03/D-03): this node's
# BotFights becomes a thin client of the Foundation's well-known arena —
# all nodes see all fighters, fights cross nodes. This is a rendezvous,
# not an authority: any node can host its own arena (same image, just
# without this var set), and an operator can remove this line entirely to
# run a fully standalone, node-local arena instead.
- ARENA_UPSTREAM_URL=https://botfights.archipelago-foundation.org
# Tells the app it is embedded (first-party) in the node dashboard's
# iframe, so it can safely disable X-Frame-Options: SAMEORIGIN — see
# server/src/app.ts in the botfight repo. Without this the 1.2.x image's
# default security headers block the dashboard iframe entirely.
- ARCHY_EMBEDDED=1
health_check:
type: http
endpoint: http://localhost:9100
path: /api/health
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
interfaces:
main:
name: Web UI
description: Bot arena and arcade fighter with controller support
type: ui
port: 9100
protocol: http
path: /
metadata:
author: Dorian
repo: https://botfights.net
icon: /assets/img/app-icons/botfights.svg
license: MIT
tags:
- bitcoin
- gaming
- arcade
- fighter
- bots
- competition
- controller
+5
View File
@@ -0,0 +1,5 @@
# BTCPay Server - uses official image
FROM btcpayserver/btcpayserver:1.12.0
# Default configuration is in the image
# No additional setup needed
+97
View File
@@ -0,0 +1,97 @@
app:
id: btcpay-server
name: BTCPay Server
version: 2.3.9
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: docker.io/btcpayserver/btcpayserver:2.3.9
pull_policy: if-not-present
network: archy-net
secret_env:
- key: BTCPAY_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
# Internal LND node. Generated by the daemon (lnd macaroon as hex +
# tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret.
# Optional: nodes without LND run btcpay without an internal node.
- key: BTCPAY_BTCLIGHTNING
secret_file: btcpay-lnd-connection
optional: true
derived_env:
- key: BTCPAY_HOST
template: "{{HOST_IP}}:23000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: archy-btcpay-db
version: ">=15.17"
- app_id: archy-nbxplorer
version: ">=2.6.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 23000
container: 49392
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/btcpay
target: /datadir
options: [rw]
environment:
- ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http
- BTCPAY_CHAINS=btc
# Plugins must live on the persistent volume: the image default
# (/root/.btcpayserver/Plugins) is container-local, so every recreate
# silently wiped installed plugins.
- BTCPAY_PLUGINDIR=/datadir/Plugins
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
health_check:
type: http
endpoint: http://localhost:49392
path: /
interval: 30s
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
sync_required: true
lightning_integration:
payment_processing: false
invoice_management: true
interfaces:
main:
name: Web UI
description: BTCPay Server dashboard
type: ui
port: 23000
protocol: http
path: /
metadata:
launch:
open_in_new_tab: true
Executable
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# Build script for Archipelago apps
# Usage: ./build.sh [app-id] [--dev]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APPS_DIR="$SCRIPT_DIR"
# Determine container runtime
RUNTIME="auto"
if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then
RUNTIME="podman"
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
RUNTIME="docker"
else
echo "❌ No container runtime available (Podman or Docker required)"
exit 1
fi
echo "🐳 Using runtime: $RUNTIME"
# Build function
build_app() {
local app_id=$1
local app_dir="$APPS_DIR/$app_id"
local dev_mode=$2
if [ ! -d "$app_dir" ]; then
echo "❌ App directory not found: $app_dir"
return 1
fi
if [ ! -f "$app_dir/Dockerfile" ]; then
echo "⚠️ No Dockerfile found for $app_id, skipping..."
return 0
fi
echo ""
echo "🔨 Building $app_id..."
local image_tag="archipelago/$app_id:latest"
if [ "$dev_mode" = "--dev" ]; then
image_tag="archipelago/$app_id:dev"
fi
cd "$app_dir"
# For Node.js apps, install dependencies first
if [ -f "package.json" ]; then
echo " Installing Node.js dependencies..."
npm install --production=false
fi
# Build the image
echo " Building container image: $image_tag"
$RUNTIME build -t "$image_tag" .
if [ $? -eq 0 ]; then
echo "$app_id built successfully: $image_tag"
else
echo "❌ Failed to build $app_id"
return 1
fi
}
# Main logic
if [ $# -eq 0 ]; then
# Build all apps
echo "🔨 Building all Archipelago apps..."
for app_dir in "$APPS_DIR"/*/; do
if [ -d "$app_dir" ] && [ -f "$app_dir/manifest.yml" ]; then
app_id=$(basename "$app_dir")
build_app "$app_id" "$@"
fi
done
else
# Build specific app
app_id=$1
shift
build_app "$app_id" "$@"
fi
echo ""
echo "✅ Build complete!"
+5
View File
@@ -0,0 +1,5 @@
# Core Lightning - uses official image
FROM elementsproject/lightningd:v23.08.2
# Default configuration is in the image
# No additional setup needed
+69
View File
@@ -0,0 +1,69 @@
app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
image: elementsproject/lightningd:v23.08.2
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: core-lightning
ports:
- host: 9736
container: 9735
protocol: tcp # P2P (using 9736 to avoid conflict with LND)
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 9835
container: 9835
protocol: tcp # gRPC
auth: none
auth_rationale: >-
Core Lightning gRPC, authenticated by mutual TLS client certificates.
volumes:
- type: bind
source: /var/lib/archipelago/core-lightning
target: /home/clightning/.lightning
options: [rw]
environment:
- BITCOIND_RPCURL=http://bitcoin-core:8332
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=bitcoin
health_check:
type: exec
endpoint: lightning-cli getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
*.log
.git
.gitignore
README.md
+39
View File
@@ -0,0 +1,39 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/wallet && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV WALLET_STORAGE=/app/wallet
ENV DWN_ENDPOINT=http://web5-dwn:3000
CMD ["node", "dist/index.js"]
+35
View File
@@ -0,0 +1,35 @@
# DID Wallet
Web5 wallet with Decentralized Identifier (DID) support.
## Building
```bash
# From the apps directory
./build.sh did-wallet
# Or manually
cd did-wallet
docker build -t archipelago/did-wallet:latest .
```
## Development
```bash
cd did-wallet
npm install
npm run dev
```
## Ports
- **8083**: Web UI (dev: 18083)
## Running Locally
```bash
docker run -p 8083:8080 \
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
-e DWN_ENDPOINT=http://localhost:13000 \
archipelago/did-wallet:latest
```
+56
View File
@@ -0,0 +1,56 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: archipelago/did-wallet:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true
+2747
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "did-wallet",
"version": "1.0.0",
"description": "Web5 DID Wallet for Archipelago",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DID Wallet</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Web5 DID Wallet</h1>
<p>Decentralized Identity Wallet for Archipelago</p>
<div id="app">
<p>Wallet interface coming soon...</p>
</div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'did-wallet' });
});
// Wallet API endpoints
app.get('/api/wallet/info', (req, res) => {
res.json({
status: 'ok',
wallet: {
dids: [],
balance: 0
}
});
});
app.post('/api/wallet/did/create', async (req, res) => {
// Placeholder for DID creation
res.json({
status: 'ok',
did: 'did:key:placeholder'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+53
View File
@@ -0,0 +1,53 @@
app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
/electrs-status to the archipelago backend on 127.0.0.1:5678.
container:
build:
context: /opt/archipelago/docker/electrs-ui
dockerfile: Dockerfile
tag: localhost/electrs-ui:local
dependencies: []
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 50002 directly on the host IP.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the Electrs screen unauthenticated on every interface.
ports:
- host: 50002
container: 50002
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:50002
path: /
interval: 30s
timeout: 5s
retries: 3
+90
View File
@@ -0,0 +1,90 @@
app:
id: electrumx
name: ElectrumX
version: 1.18.0
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
image: 146.59.87.168:3000/lfg2025/electrumx:v1.18.0
pull_policy: if-not-present
network: archy-net
data_uid: "1000:1000"
entrypoint: ["sh", "-lc"]
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
# on which version the node runs (multi-version switch) — probe which name
# resolves on archy-net instead of hardcoding knots, which left electrumx
# permanently disconnected (block index 0) on core nodes.
custom_args:
- >-
for h in bitcoin-knots bitcoin-core; do
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
done;
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
exec electrumx_server
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
dependencies:
- app_id: bitcoin-knots
version: ">=26.0"
- storage: 50Gi
- bitcoin:archival
resources:
cpu_limit: 0
memory_limit: 6Gi
disk_limit: 50Gi
security:
capabilities: [DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 50001
container: 50001
protocol: tcp
auth: none
auth_rationale: >-
Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/electrumx
target: /data
options: [rw]
environment:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=1024
- MAX_SEND=10000000
# The ElectrumX dashboard tile is served by the host-networked companion UI
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
# so the tile resolves a launch URL without relying on the hand-maintained
# override in appSessionConfig.ts (which the generator can clobber). The
# backend only validates this block — it does not proxy/health-check it.
interfaces:
main:
name: Web UI
description: ElectrumX server status and connection details
type: ui
port: 50002
protocol: http
health_check:
type: tcp
endpoint: localhost:50001
interval: 30s
timeout: 5s
retries: 3
start_period: 10m
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
+102
View File
@@ -0,0 +1,102 @@
app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
# fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
# this to the node registry. Pin the tag to match the REST shapes coded in
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.1
pull_policy: if-not-present
network: archy-net
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
# unreachable default never crash-loops. All config comes from FMCD_* env
# below. Nodes can join more federations via wallet.fedimint-join.
# Auto-generated on first install (random hex, 0600, rootless-owned) so the
# app needs no host provisioning. The wallet bridge reads the same file.
generated_secrets:
- name: fmcd-password
kind: hex16
secret_env:
- key: FMCD_PASSWORD
secret_file: fmcd-password
data_uid: "1000:1000"
# NOTE: this is a CLIENT, not the guardian — it does not require the local
# `fedimint` app. It joins external federations (default below), so it can be
# bundled standalone on every node.
dependencies:
- storage: 2Gi
resources:
# fmcd's embedded iroh networking can hot-loop on relay/hole-punch retries
# on NAT'd nodes that reach the federation neither directly nor via iroh's
# public relays, pegging its whole allotment. Cap it low so a stuck instance
# can't starve the node (steady-state is <3% of a core; joins are brief);
# the fmcd-run watchdog additionally restarts a sustained-hot process.
cpu_limit: 1
memory_limit: 1Gi
disk_limit: 2Gi
security:
# fmcd's `fmcd-run` launcher chowns its /data (existing federation DB) on
# every start. With the default `cap_drop: ALL` and no caps added back, that
# chown fails and fmcd dies "Operation not permitted (os error 1)" — but ONLY
# once /data holds a joined federation (a fresh/empty dir needs no chown, so
# it appeared to work). Restore the standard container capability set so the
# startup chown succeeds (#7). Verified by bisection on .116: these caps make
# fmcd boot + serve /v2/*; DAC_OVERRIDE or SETUID/SETGID alone do NOT.
capabilities: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"]
readonly_root: true
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
# wallet bridge targets. ("open" is not a valid policy — it made the loader
# skip this whole manifest, so fmcd never ran and federations never joined.)
# Lock down once the default federation's reachability model is finalized.
network_policy: bridge
ports:
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
# host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178.
- host: 8178
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
# Same dir the first-boot bundled path uses + where the wallet bridge reads
# the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned.
- type: bind
source: /var/lib/archipelago/fmcd
target: /data
options: [rw]
environment:
- FMCD_ADDR=0.0.0.0:8080
- FMCD_MODE=rest
- FMCD_DATA_DIR=/data
# Default federation joined out-of-the-box (guardian on .116, iroh
# transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so
# it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in
# core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate
# join reliability from a real second node before relying on auto-bundle.
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
# fmcd serves only authenticated /v2/* routes — there is no unauthenticated
# /health endpoint, so an http probe to /health 404s forever and pins the
# container in "(starting)". fmcd's own image also ships neither curl nor wget.
# Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the
# host-side lifecycle layer verifies reachability, so the container reports
# "running" instead of a perpetual false-negative "(starting)".
health_check:
type: tcp
endpoint: localhost:8080
interval: 30s
timeout: 5s
retries: 3
+98
View File
@@ -0,0 +1,98 @@
app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: 146.59.87.168:3000/lfg2025/gatewayd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
# container is actually running (Knots, Core, or any future distro archy
# ships), so the gateway is never pinned to one node's container name.
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
custom_args:
- >-
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
else
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
fi
derived_env:
- key: FM_BITCOIND_URL
template: "http://{{BITCOIN_HOST}}:8332"
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
# the hash passed to gatewayd, `fedimint-gateway-hash.pw` the plaintext for
# any client that must authenticate. Self-heals a wrongly root-owned hash.
generated_secrets:
- name: fedimint-gateway-hash
kind: bcrypt
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
- key: FEDI_HASH
secret_file: fedimint-gateway-hash
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: fedimint
version: ">=0.10.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
ports:
- host: 8176
container: 8176
protocol: tcp
auth: none
auth_rationale: >-
Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash)
and reached by federation peers and clients that cannot hold a browser session.
- host: 9737
container: 9737
protocol: tcp
auth: none
auth_rationale: >-
LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and
encrypts the connection itself.
volumes:
- type: bind
source: /var/lib/archipelago/fedimint-gateway
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/lnd
target: /lnd
options: [ro]
environment:
- FM_BITCOIND_USERNAME=archipelago
health_check:
type: http
endpoint: http://localhost:8176
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
+5
View File
@@ -0,0 +1,5 @@
# Fedimint - uses official image
FROM fedimint/fedimint:0.3.0
# Default configuration is in the image
# No additional setup needed
+114
View File
@@ -0,0 +1,114 @@
app:
id: fedimint
name: Fedimint Guardian
version: 0.10.0
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
- |-
until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do
echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL...";
sleep 30;
done;
exec fedimintd
derived_env:
- key: FM_P2P_URL
template: fedimint://{{HOST_MDNS}}:8173
- key: FM_API_URL
template: ws://{{HOST_MDNS}}:8174
# Resolves to whichever bitcoin container is running (Knots/Core/future
# distro) instead of a hardcoded name — the guardian works on any node
# regardless of which Bitcoin software it runs.
- key: FM_BITCOIND_URL
template: "http://{{BITCOIN_HOST}}:8332"
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- storage: 20Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
ports:
- host: 8173
container: 8173
protocol: tcp
auth: none
auth_rationale: >-
Fedimint guardian consensus. Other guardians speak the federation's own
authenticated protocol here; a login page would break consensus.
- host: 8174
container: 8174
protocol: tcp
auth: none
auth_rationale: >-
Fedimint guardian API for federation clients, which authenticate to the
federation itself and cannot hold a browser session.
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
# 8175 is NOT declared here. It is served by the archy-fedimint-ui
# companion, a different container, and declaring it on this app made the
# orchestrator try to publish 8175 from fedimintd — colliding with the
# companion that already holds it, so start_container failed forever and
# fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx
# is pinned to 127.0.0.1, which is what actually closes that port; the
# gate reports it rather than fronting it.
- host: 8177
container: 8175
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/fedimint
target: /data
options: [rw]
environment:
- FM_DATA_DIR=/data
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
# hardcoded name — do not re-add it here.
- FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173
- FM_BIND_API=0.0.0.0:8174
- FM_BIND_UI=0.0.0.0:8175
health_check:
type: http
endpoint: http://localhost:8175
path: /
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Guardian UI
description: Fedimint Guardian wait/proxy UI
type: ui
port: 8175
protocol: http
path: /
bitcoin_integration:
rpc_access: admin
sync_required: true
+55
View File
@@ -0,0 +1,55 @@
app:
id: filebrowser
name: File Browser
version: 2.27.0
description: Baseline Archipelago file manager service.
container:
image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0
pull_policy: if-not-present
network: archy-net
custom_args: ["--config", "/data/.filebrowser.json"]
data_uid: "100000:100000"
dependencies:
- storage: 10Gi
resources:
memory_limit: 256Mi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8083
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/filebrowser
target: /srv
options: [rw]
- type: bind
source: /var/lib/archipelago/filebrowser-data
target: /data
options: [rw]
environment: []
health_check:
type: http
endpoint: http://localhost:80
path: /health
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+57
View File
@@ -0,0 +1,57 @@
app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
restart, and stable-channel daemon updates) go through the existing
fips.* RPC methods, authenticated by the browser's own archipelago
session — there is no separate secret to manage.
container:
build:
context: /opt/archipelago/docker/fips-ui
dockerfile: Dockerfile
tag: localhost/fips-ui:local
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8336 directly on the host IP and
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
# intentionally empty because host networking bypasses port mapping.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the FIPS mesh screen unauthenticated on every interface.
ports:
- host: 8336
container: 8336
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8336
path: /
interval: 30s
timeout: 5s
retries: 3
+92
View File
@@ -0,0 +1,92 @@
app:
id: gitea
name: Gitea
version: "1.23"
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
container:
image: docker.io/gitea/gitea:1.23
pull_policy: if-not-present
dependencies:
- storage: 500Mi
resources:
memory_limit: 256Mi
disk_limit: 500Mi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: false
network_policy: bridge
ports:
- host: 3001
container: 3000
protocol: tcp
bind: 127.0.0.1
auth: gated
- host: 2222
container: 22
protocol: tcp
auth: none
auth_rationale: >-
Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.
volumes:
- type: bind
source: /var/lib/archipelago/gitea/data
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/gitea/config
target: /etc/gitea
options: [rw]
environment:
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__SSH_PORT=2222
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__LFS_START_SERVER=true
- GITEA__packages__ENABLED=true
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
health_check:
type: http
endpoint: http://localhost:3000
path: /
interval: 120s
timeout: 30s
retries: 5
interfaces:
main:
name: Web UI
description: Gitea web interface
type: ui
port: 3001
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/gitea.svg
repo: https://gitea.com
tier: optional
launch:
open_in_new_tab: true
features:
- Git repositories with web UI
- Built-in container/package registry
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
+5
View File
@@ -0,0 +1,5 @@
# Grafana - uses official image
FROM grafana/grafana:10.2.0
# Default configuration is in the image
# No additional setup needed
+59
View File
@@ -0,0 +1,59 @@
app:
id: grafana
name: Grafana
version: 10.2.0
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
image: grafana/grafana:10.2.0
image_signature: cosign://...
pull_policy: if-not-present
data_uid: "472:472"
dependencies:
- storage: 5Gi
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: grafana
ports:
- host: 3000
container: 3000
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/grafana
target: /var/lib/grafana
options: [rw]
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_SERVER_ROOT_URL=http://localhost:3000
- GF_INSTALL_PLUGINS=
health_check:
type: http
endpoint: http://localhost:3000
path: /api/health
interval: 30s
timeout: 30s
retries: 5
metadata:
launch:
open_in_new_tab: true
+5
View File
@@ -0,0 +1,5 @@
# Home Assistant - uses official image
FROM homeassistant/home-assistant:2026.7.3
# Default configuration is in the image
# No additional setup needed
+69
View File
@@ -0,0 +1,69 @@
app:
id: homeassistant
name: Home Assistant
version: 2026.7.3
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW]
readonly_root: false # Home Assistant needs write access
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: home-assistant
ports:
- host: 8123
container: 8123
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/home-assistant
target: /config
options: [rw]
devices: []
environment:
- TZ=UTC
health_check:
type: tcp
endpoint: localhost:8123
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Home Assistant dashboard
type: ui
port: 8123
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/homeassistant.png
category: home
author: Home Assistant
repo: https://github.com/home-assistant/core
launch:
open_in_new_tab: true
+58
View File
@@ -0,0 +1,58 @@
app:
id: immich-postgres
name: Immich Postgres
version: "14-vectorchord0.4.3-pgvectors0.2.0"
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
# Container named immich_postgres (underscore) to match the runtime's existing
# per-app references (lifecycle/health/crash-recovery/config) and serve as the
# server's DB_HOSTNAME alias. Top-level key → serde(flatten) → extensions →
# compute_container_name.
container_name: immich_postgres
container:
image: 146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0
pull_policy: if-not-present
network: archy-net
# postgres drops to its own uid (container 999 → host 100998 under rootless),
# so the data dir must be owned by that mapped uid — mirrors archy-btcpay-db.
# Verified on .228: the live immich-db is owned 100998. Without this a FRESH
# install's dir would be service-user-owned and postgres would EACCES.
data_uid: "100998:100998"
generated_secrets:
- name: immich-db-password
kind: hex32
secret_env:
- key: POSTGRES_PASSWORD
secret_file: immich-db-password
dependencies:
- storage: 40Gi
resources:
memory_limit: 2Gi
disk_limit: 40Gi
security:
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/immich-db
target: /var/lib/postgresql/data
options: [rw]
environment:
- POSTGRES_USER=postgres
- POSTGRES_DB=immich
health_check:
type: tcp
endpoint: localhost:5432
interval: 30s
timeout: 5s
retries: 3
+37
View File
@@ -0,0 +1,37 @@
app:
id: immich-redis
name: Immich Redis
version: "7-alpine"
description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references
# and serve as the server's REDIS_HOSTNAME alias on archy-net.
container_name: immich_redis
container:
image: 146.59.87.168:3000/lfg2025/valkey:7-alpine
pull_policy: if-not-present
network: archy-net
dependencies: []
resources:
memory_limit: 128Mi
security:
capabilities: [SETGID, SETUID]
readonly_root: false
network_policy: isolated
ports: []
volumes: []
environment: []
health_check:
type: tcp
endpoint: localhost:6379
interval: 30s
timeout: 5s
retries: 3
+82
View File
@@ -0,0 +1,82 @@
app:
id: immich
name: Immich
version: "2.7.4"
description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
# + icon). The container is named "immich_server" so it matches the runtime's
# existing per-app container references (lifecycle/health/crash-recovery/ports);
# `container_name` is a top-level app key (captured by serde(flatten) into
# extensions, read by compute_container_name). It reaches its backends by their
# underscore aliases on archy-net (DB_HOSTNAME / REDIS_HOSTNAME below).
container_name: immich_server
container:
image: 146.59.87.168:3000/lfg2025/immich-server:release
pull_policy: if-not-present
network: archy-net
secret_env:
- key: DB_PASSWORD
secret_file: immich-db-password
dependencies:
- app_id: immich-postgres
- app_id: immich-redis
- storage: 200Gi
resources:
memory_limit: 2Gi
disk_limit: 200Gi
security:
# Runs as container root over a data tree the legacy installer chowned
# to the subuid range (host 100000 = container uid 1). Without
# DAC_OVERRIDE the server EACCESes writing upload/encoded-video the
# moment the container is recreated against this manifest (latent until
# the 2026-07-05 secret-env migration recreated it). Same cap set as
# immich-postgres minus the setuid pair it doesn't use.
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER]
readonly_root: false
network_policy: isolated
ports:
- host: 2283
container: 2283
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/immich
target: /usr/src/app/upload
options: [rw]
environment:
- DB_HOSTNAME=immich_postgres
- DB_USERNAME=postgres
- DB_DATABASE_NAME=immich
- REDIS_HOSTNAME=immich_redis
- UPLOAD_LOCATION=/usr/src/app/upload
health_check:
type: http
endpoint: http://localhost:2283
path: /api/server/ping
interval: 30s
timeout: 5s
retries: 20
interfaces:
main:
name: Web UI
description: Immich photo library
type: ui
port: 2283
protocol: http
path: /
metadata:
launch:
open_in_new_tab: true
+77
View File
@@ -0,0 +1,77 @@
app:
id: indeedhub-api
name: IndeedHub API
version: "1.0.0"
description: IndeedHub backend API (Nostr auth, media, payments).
category: community
# Hyphen name matches runtime references + the live container (adoption);
# alias `api` is the short hostname the frontend nginx proxies to
# (http://api:4000). Reaches its backends by their short aliases
# (postgres/redis/minio) on indeedhub-net — unchanged from the legacy installer.
container_name: indeedhub-api
container:
image: 146.59.87.168:3000/lfg2025/indeedhub-api:1.0.0
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [api]
# The JWT signing secret is owned here (no backend container owns it); the
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
# only consumed here. ensure_generated_secrets no-ops when a file already
# exists, so live values on .228 are preserved (postgres pw is fixed at
# PGDATA init — regenerating would lock the API out).
generated_secrets:
- name: indeedhub-jwt
kind: hex32
secret_env:
- key: DATABASE_PASSWORD
secret_file: indeedhub-db-password
- key: AWS_SECRET_KEY
secret_file: indeedhub-minio-password
- key: NOSTR_JWT_SECRET
secret_file: indeedhub-jwt
dependencies:
- app_id: indeedhub-postgres
- app_id: indeedhub-redis
- app_id: indeedhub-minio
resources:
memory_limit: 2Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports: []
volumes: []
environment:
- PORT=4000
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_USER=indeedhub
- DATABASE_NAME=indeedhub
- QUEUE_HOST=redis
- QUEUE_PORT=6379
- S3_ENDPOINT=http://minio:9000
- AWS_REGION=us-east-1
- AWS_ACCESS_KEY=indeeadmin
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- S3_PUBLIC_BUCKET_URL=/storage
- NOSTR_JWT_EXPIRES_IN=7d
# Fixed across the fleet (envelope-encryption master key baked by the legacy
# installer); not node-specific, so a plain env literal, not a secret.
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
- ENVIRONMENT=production
health_check:
type: tcp
endpoint: localhost:4000
interval: 30s
timeout: 5s
retries: 10
+51
View File
@@ -0,0 +1,51 @@
app:
id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker
version: "1.0.0"
description: IndeedHub background media transcoding worker.
category: community
# Hyphen name matches runtime references + the live container (adoption). No
# network_alias: nothing connects TO the worker — it only dials out to
# postgres/redis/minio (resolved by their aliases on indeedhub-net).
container_name: indeedhub-ffmpeg
container:
image: 146.59.87.168:3000/lfg2025/indeedhub-ffmpeg:1.0.0
pull_policy: if-not-present
network: indeedhub-net
secret_env:
- key: DATABASE_PASSWORD
secret_file: indeedhub-db-password
- key: AWS_SECRET_KEY
secret_file: indeedhub-minio-password
dependencies:
- app_id: indeedhub-api
resources:
memory_limit: 4Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports: []
volumes: []
environment:
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_USER=indeedhub
- DATABASE_NAME=indeedhub
- QUEUE_HOST=redis
- QUEUE_PORT=6379
- S3_ENDPOINT=http://minio:9000
- AWS_REGION=us-east-1
- AWS_ACCESS_KEY=indeeadmin
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- ENVIRONMENT=production
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
+60
View File
@@ -0,0 +1,60 @@
app:
id: indeedhub-minio
name: IndeedHub MinIO
version: "RELEASE.2024-11-07T00-52-20Z"
description: MinIO S3-compatible object storage for IndeedHub media.
category: community
# Hyphen name matches runtime references + the live container (adoption);
# alias `minio` is the short hostname the api/ffmpeg use (S3_ENDPOINT=
# http://minio:9000) AND the frontend nginx proxies to (http://minio:9000).
container_name: indeedhub-minio
container:
image: 146.59.87.168:3000/lfg2025/minio:RELEASE.2024-11-07T00-52-20Z
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [minio]
# `server /data` — the minio entrypoint args from the legacy installer.
custom_args: [server, /data]
generated_secrets:
- name: indeedhub-minio-password
kind: hex32
secret_env:
- key: MINIO_ROOT_PASSWORD
secret_file: indeedhub-minio-password
dependencies:
- storage: 50Gi
resources:
memory_limit: 1Gi
disk_limit: 50Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports: []
# Named volume matches the live indeedhub-minio-data volume on .228.
volumes:
- type: volume
source: indeedhub-minio-data
target: /data
options: [rw]
# MINIO_ROOT_USER "indeeadmin" is the fixed admin identity baked by the legacy
# installer (api/ffmpeg use it as AWS_ACCESS_KEY); the password is the
# generated secret above. Not secret, so it stays a plain env value.
environment:
- MINIO_ROOT_USER=indeeadmin
health_check:
type: http
endpoint: http://localhost:9000
path: /minio/health/live
interval: 30s
timeout: 5s
retries: 5
+59
View File
@@ -0,0 +1,59 @@
app:
id: indeedhub-postgres
name: IndeedHub Postgres
version: "16.13-alpine"
description: Postgres database backend for IndeedHub.
category: community
# Container named indeedhub-postgres (hyphen) to match the runtime's existing
# per-app references (health_monitor tiers/deps, crash_recovery) and the live
# .228 install, so the orchestrator ADOPTS the running container instead of
# recreating it. `network_aliases: [postgres]` keeps the short hostname the
# api/ffmpeg/relay reach by (DATABASE_HOST=postgres) resolvable on
# indeedhub-net, reproducing the legacy `--network-alias postgres`.
container_name: indeedhub-postgres
container:
image: 146.59.87.168:3000/lfg2025/postgres:16.13-alpine
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [postgres]
generated_secrets:
- name: indeedhub-db-password
kind: hex32
secret_env:
- key: POSTGRES_PASSWORD
secret_file: indeedhub-db-password
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
readonly_root: false
network_policy: isolated
ports: []
# Named podman volume (matches the live indeedhub-postgres-data volume on .228);
# preserves all existing database content across the migration.
volumes:
- type: volume
source: indeedhub-postgres-data
target: /var/lib/postgresql/data
options: [rw]
environment:
- POSTGRES_USER=indeedhub
- POSTGRES_DB=indeedhub
health_check:
type: tcp
endpoint: localhost:5432
interval: 30s
timeout: 5s
retries: 3
+51
View File
@@ -0,0 +1,51 @@
app:
id: indeedhub-redis
name: IndeedHub Redis
version: "7.4.8-alpine"
description: Redis queue/cache backend for IndeedHub.
category: community
# Hyphen name matches runtime references + the live container (adoption);
# alias `redis` is the short hostname the api/ffmpeg reach (QUEUE_HOST=redis).
container_name: indeedhub-redis
container:
image: 146.59.87.168:3000/lfg2025/redis:7.4.8-alpine
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [redis]
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
security:
# The alpine entrypoint runs as container-root, `find`s /data to chown
# anything not owned by the redis user, then su-execs to it. Under the
# orchestrator's --cap-drop=ALL, root cannot traverse the 0700
# appendonlydir owned by uid 999 without DAC_OVERRIDE (observed
# crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's
# -exec chown needs on adopted legacy data.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
readonly_root: false
network_policy: isolated
ports: []
# Named volume matches the live indeedhub-redis-data volume on .228.
volumes:
- type: volume
source: indeedhub-redis-data
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:6379
interval: 30s
timeout: 5s
retries: 3
+47
View File
@@ -0,0 +1,47 @@
app:
id: indeedhub-relay
name: IndeedHub Nostr Relay
version: "0.9.0"
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
category: community
# Hyphen name matches runtime references + the live container (adoption);
# alias `relay` is the short hostname the frontend nginx proxies to
# (http://relay:8080 for the /relay websocket).
container_name: indeedhub-relay
container:
image: 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [relay]
dependencies:
- storage: 2Gi
resources:
memory_limit: 256Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports: []
# Named volume matches the live indeedhub-relay-data volume on .228.
volumes:
- type: volume
source: indeedhub-relay-data
target: /usr/src/app/db
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:8080
interval: 30s
timeout: 5s
retries: 3
+78
View File
@@ -0,0 +1,78 @@
# Multi-stage Dockerfile for Indeehub Frontend (Next.js)
# Build: podman build -t localhost/indeedhub:latest -f apps/indeedhub/Dockerfile /path/to/indeehub-frontend
# Run: podman run -d --name indeedhub -p 8190:3000 localhost/indeedhub:latest
# ── Stage 1: Dependencies ──
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# ── Stage 2: Build ──
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Inject standalone output mode for containerized deployment
RUN sed -i 's/reactStrictMode: true,/reactStrictMode: true, output: "standalone",/' next.config.js
# Build-time environment — connects to Indeehub production services
ENV NEXT_PUBLIC_APP_ENVIRONMENT=production
ENV NEXT_PUBLIC_APP_URL=http://localhost:8190
ENV NEXT_PUBLIC_API_URL=https://staging-api.indeehub.studio
ENV NEXT_PUBLIC_S3_PRIVATE_BUCKET=indeehub-private
ENV NEXT_PUBLIC_S3_PUBLIC_BUCKET=indeehub-public
ENV NEXT_PUBLIC_ENABLE_APPROVAL_FLOW=false
ENV NEXT_TELEMETRY_DISABLED=1
# Remove shaka-player .d.ts files that break the build (per package.json build script)
RUN rm -f ./node_modules/shaka-player/dist/*.d.ts
# Patch: replace home page with error-resilient version that doesn't crash
# when the Webflow landing page URL is unreachable from the container.
RUN printf '%s\n' \
"import axios from 'axios';" \
"import { HomeClient } from './page.client';" \
"" \
"export const dynamic = 'force-dynamic';" \
"" \
"export default async function Home() {" \
" try {" \
" const response = await axios('https://indeehub-30479a.webflow.io/', { timeout: 8000 });" \
" if (response.status !== 200) throw new Error('Bad status');" \
" const html = String(response.data)" \
" .replace('https://cdn.prod.website-files.com/img/favicon.ico', '/favicon.ico')" \
" .replace('https://cdn.prod.website-files.com/img/webclip.png', '/favicon.ico');" \
" return <HomeClient html={html} />;" \
" } catch {" \
" return <HomeClient html=\"<html><head><title>IndeeHub</title></head><body style='background:#000;color:#fff;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1>IndeeHub</h1><p>Loading content...</p><script>setTimeout(function(){location.reload()},5000)</script></div></body></html>\" />;" \
" }" \
"}" > src/app/page.tsx
RUN npm run build
# ── Stage 3: Runner ──
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy standalone build output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
+53
View File
@@ -0,0 +1,53 @@
# Indeehub — Bitcoin Documentary Streaming
Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider.
## Building the Image
The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`.
### Option 1: Use the build script
```bash
# From archy repo root
./apps/indeedhub/build-from-prototype.sh
```
### Option 2: Build from source directory
```bash
cd ~/Projects/indeehub-frontend
podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile .
```
## Installing from App Store
1. **Build the image** using one of the options above (must exist before install)
2. Go to **Dashboard -> App Store** (Marketplace)
3. Find **Indeehub** and click **Install**
4. On first launch, pick a Nostr identity to sign in with
5. The app appears in **My Apps** once the container is running
## Port
- Web UI: 8190 (maps to container port 3000)
## Container
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry)
- Runtime: Node.js 20 (Next.js standalone)
- Port: 8190 -> 3000
- Read-only root filesystem with tmpfs for /tmp and .next/cache
## Nostr Identity
On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy.
## Services
The app connects to the following external services (configured at build time):
- Indeehub API (content, auth, streaming)
- AWS S3 (media storage via CloudFront CDN)
- Nostr relays (via NIP-07 provider from Archipelago)
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Build Indeehub container image from the indeehub-frontend project
# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend"
FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}"
IMAGE_TAG="localhost/indeedhub:latest"
if [ ! -d "$FRONTEND_DIR" ]; then
echo "Indeehub frontend not found at: $FRONTEND_DIR"
echo " Set path: $0 /path/to/indeehub-frontend"
exit 1
fi
if [ ! -f "$FRONTEND_DIR/package.json" ]; then
echo "No package.json found in $FRONTEND_DIR — is this the right directory?"
exit 1
fi
# Determine container runtime
RUNTIME="podman"
if ! command -v podman >/dev/null 2>&1; then
RUNTIME="docker"
fi
echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile"
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR"
echo "Built $IMAGE_TAG"
echo ""
echo "You can now install Indeehub from the App Store in Archipelago."
echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG"
+106
View File
@@ -0,0 +1,106 @@
app:
id: indeedhub
name: IndeeHub
version: "1.0.0"
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: community
# The user-facing launcher (app_id "indeedhub"). Container is named "indeedhub"
# (matches the runtime's per-app references + the live container, so the
# orchestrator adopts it). Its nginx (listen 7777) proxies to the backends by
# their short aliases on indeedhub-net: api:4000, minio:9000, relay:8080.
container_name: indeedhub
container:
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
pull_policy: if-not-present
network: indeedhub-net
dependencies:
- app_id: indeedhub-api
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
# nginx master runs as root and drops workers to the nginx user (uid/gid
# 101) — needs SET{UID,GID}; CHOWN + DAC_OVERRIDE let it own + write the
# proxy cache under the tmpfs /var/cache/nginx. The orchestrator does
# --cap-drop=ALL, so (unlike the legacy `podman run` default caps) these
# must be declared or nginx workers die with "setgid(101) failed".
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
readonly_root: false
network_policy: isolated
ports:
- host: 7778
container: 7777
protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
bind: 127.0.0.1
auth: gated
# Writable scratch the baked nginx needs; matches the legacy installer's
# --tmpfs /run + /var/cache/nginx.
volumes:
- type: tmpfs
target: /run
options: [rw, nosuid, nodev, size=16m]
- type: tmpfs
target: /var/cache/nginx
options: [rw, nosuid, nodev, size=32m]
environment: []
# Defensive + idempotent. The current indeedhub:1.0.0 image already bakes the
# iframe-friendly nginx (X-Frame-Options omitted, nostr-provider.js present +
# <script> injected), so these are mostly no-ops on that tag — but they keep
# the app iframe-loadable + the provider script fresh for any image build that
# predates the bake. copy_from_host pulls /opt/archipelago/web-ui/nostr-provider.js
# (kept current by frontend OTA releases). Replaces the legacy hardcoded
# patch_indeedhub_nostr_provider() Rust hook.
hooks:
post_install:
- exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"]
- copy_from_host:
src: "web-ui/nostr-provider.js"
dest: "/usr/share/nginx/html/nostr-provider.js"
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
- exec: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
# startup (before workers), so this passes immediately and stays green under
# load. An http check of / runs the SPA + sub_filter and false-fails when the
# node is busy → the reconciler then treats the frontend as wedged and
# recreates it in a loop (observed churning the frontend on the loaded .198).
health_check:
type: tcp
endpoint: localhost:7777
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
interfaces:
main:
name: Web UI
description: Stream Bitcoin documentaries with Nostr identity
type: ui
port: 7778
protocol: http
path: /
metadata:
author: Indeehub Team
icon: /assets/img/app-icons/indeedhub.png
website: https://indeedhub.com
repo: https://github.com/indeedhub/indeedhub
license: MIT
tags:
- bitcoin
- documentary
- streaming
- media
- education
- nostr
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Build and push Indeehub container image to a registry
# Usage: ./push-to-registry.sh [version]
#
# Environment variables:
# REGISTRY - Registry host (default: ghcr.io)
# NAMESPACE - Registry namespace (default: archipelago-os)
# RUNTIME - Container runtime (default: podman)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
VERSION="${1:-latest}"
REGISTRY="${REGISTRY:-146.59.87.168:3000}"
NAMESPACE="${NAMESPACE:-lfg2025}"
IMAGE_NAME="indeedhub"
RUNTIME="${RUNTIME:-podman}"
FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}"
LATEST_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest"
if [ ! -d "$FRONTEND_DIR" ]; then
echo "Indeehub frontend not found at: $FRONTEND_DIR"
echo "Set INDEEHUB_FRONTEND=/path/to/indeehub-frontend"
exit 1
fi
echo "=== Indeehub Container Registry Push ==="
echo "Source: $FRONTEND_DIR"
echo "Image: $FULL_TAG"
echo "Runtime: $RUNTIME"
echo ""
# Step 1: Build for linux/amd64 (target architecture)
echo "[1/3] Building image..."
$RUNTIME build --platform linux/amd64 \
-t "$FULL_TAG" \
-t "$LATEST_TAG" \
-t "localhost/${IMAGE_NAME}:latest" \
-t "localhost/${IMAGE_NAME}:${VERSION}" \
-f "$SCRIPT_DIR/Dockerfile" \
"$FRONTEND_DIR"
echo "[2/3] Pushing to registry..."
# Login check
if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then
echo ""
echo "Not logged in to $REGISTRY."
echo "Run: $RUNTIME login $REGISTRY"
exit 1
fi
$RUNTIME push "$FULL_TAG"
if [ "$VERSION" != "latest" ]; then
$RUNTIME push "$LATEST_TAG"
fi
echo ""
echo "[3/3] Done!"
echo ""
echo "Image pushed: $FULL_TAG"
if [ "$VERSION" != "latest" ]; then
echo "Also tagged: $LATEST_TAG"
fi
echo ""
echo "Federated nodes can now install via:"
echo " podman pull $FULL_TAG"
echo ""
echo "Update marketplace dockerImage to: $FULL_TAG"
+63
View File
@@ -0,0 +1,63 @@
app:
id: jellyfin
name: Jellyfin
version: 10.8.13
description: Free media server. Stream movies, music, and photos.
container:
image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 8096
container: 8096
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/jellyfin/config
target: /config
options: [rw]
- type: bind
source: /var/lib/archipelago/jellyfin/cache
target: /cache
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:8096
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Jellyfin media dashboard
type: ui
port: 8096
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/jellyfin.webp
category: data
author: Jellyfin
repo: https://github.com/jellyfin/jellyfin
+5
View File
@@ -0,0 +1,5 @@
# Lightning Stack - uses official image
FROM lightninglabs/lightning-stack:v0.12.0
# Default configuration is in the image
# No additional setup needed
+79
View File
@@ -0,0 +1,79 @@
app:
id: lightning-stack
name: Lightning Stack
version: 0.12.0
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container:
image: lightninglabs/lightning-stack:v0.12.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
- storage: 50Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 50Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: lightning-stack
ports:
- host: 9738
container: 9735
protocol: tcp # P2P
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 10010
container: 10009
protocol: tcp # gRPC
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
source: /var/lib/archipelago/lightning-stack
target: /root/.lightning
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=mainnet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /v1/getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+65
View File
@@ -0,0 +1,65 @@
app:
id: lnd-ui
name: LND UI
version: 1.0.0
description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched
via an absolute URL that the host nginx routes to the archipelago
backend on 127.0.0.1:5678, so no upstream auth is baked in.
container:
build:
context: /opt/archipelago/docker/lnd-ui
dockerfile: Dockerfile
tag: localhost/lnd-ui:local
dependencies:
- app_id: lnd
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: host
# Host networking: the container's nginx listens on 18083 directly (see
# docker/lnd-ui/nginx.conf), because it has to proxy the archipelago backend
# on 127.0.0.1:5678 same-origin — a bridge container cannot reach that, and
# the cross-origin fallback broke the app on http-only nodes. `ports:` is
# intentionally empty because host networking bypasses port mapping, exactly
# as in apps/bitcoin-ui/manifest.yml.
#
# This previously declared `bridge` with 18083:80, which publishes the host
# port to a container port where nothing listens. scripts/container-specs.sh
# carried the identical mistake and was fixed alongside this; recreating from
# it on archi-dev-box left :18083 refusing connections.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the LND screen unauthenticated on every interface.
ports:
- host: 18083
container: 18083
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:18083
path: /
interval: 30s
timeout: 5s
retries: 3
+5
View File
@@ -0,0 +1,5 @@
# LND - uses official image
FROM lightninglabs/lnd:v0.18.0
# Default configuration is in the image
# No additional setup needed
+80
View File
@@ -0,0 +1,80 @@
app:
id: lnd
name: LND
version: 0.18.4
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
pull_policy: if-not-present
network: archy-net
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
# Core — resolved at apply time from host facts. Hardcoding either breaks
# LND's chain backend connection on the other (lnd.conf is likewise
# resolved in lnd::ensure_config).
derived_env:
- key: BITCOIND_HOST
template: "{{BITCOIN_HOST}}"
secret_env:
- key: BITCOIND_RPCPASS
secret_file: bitcoin-rpc-password
data_uid: "100000:100000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW]
readonly_root: false
network_policy: isolated
ports:
- host: 9735
container: 9735
protocol: tcp
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 10009
container: 10009
protocol: tcp
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly.
- host: 18080
container: 8080
protocol: tcp
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client.
volumes:
- type: bind
source: /var/lib/archipelago/lnd
target: /root/.lnd
options: [rw]
environment:
- BITCOIND_RPCUSER=archipelago
- NETWORK=mainnet
health_check:
type: tcp
endpoint: localhost:10009
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+77
View File
@@ -0,0 +1,77 @@
app:
id: mempool-api
name: Mempool API
version: 3.0.0
description: Backend API for mempool explorer.
container:
image: 146.59.87.168:3000/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
# Core — resolved at apply time from host facts (B12). Hardcoding either
# breaks mempool's RPC connection on the other.
derived_env:
- key: CORE_RPC_HOST
template: "{{BITCOIN_HOST}}"
secret_env:
- key: CORE_RPC_PASSWORD
secret_file: bitcoin-rpc-password
- key: DATABASE_PASSWORD
secret_file: mempool-db-password
dependencies:
- app_id: bitcoin-knots
version: ">=26.0"
- app_id: electrumx
version: ">=1.18.0"
- app_id: archy-mempool-db
version: ">=11.4.10"
- bitcoin:archival
resources:
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 8999
container: 8999
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/mempool
target: /data
options: [rw]
environment:
- MEMPOOL_BACKEND=electrum
- ELECTRUM_HOST=electrumx
- ELECTRUM_PORT=50001
- ELECTRUM_TLS_ENABLED=false
- CORE_RPC_PORT=8332
- CORE_RPC_USERNAME=archipelago
- DATABASE_ENABLED=true
- DATABASE_HOST=archy-mempool-db
- DATABASE_DATABASE=mempool
- DATABASE_USERNAME=mempool
health_check:
type: http
endpoint: http://localhost:8999
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
+5
View File
@@ -0,0 +1,5 @@
# Mempool - uses official image
FROM mempool/mempool:v2.5.0
# Default configuration is in the image
# No additional setup needed
+62
View File
@@ -0,0 +1,62 @@
app:
id: mempool
name: Mempool Explorer
version: 3.0.0
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
- storage: 20Gi
- bitcoin:archival
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: mempool
ports:
- host: 4080
container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080)
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/mempool
target: /data
options: [rw]
environment:
- MEMPOOL_BACKEND=electrum
- MEMPOOL_BITCOIN_HOST=bitcoin-core
- MEMPOOL_BITCOIN_PORT=8332
- MEMPOOL_BITCOIN_USER=${BITCOIN_RPC_USER}
- MEMPOOL_BITCOIN_PASSWORD=${BITCOIN_RPC_PASSWORD}
health_check:
type: http
endpoint: http://localhost:4080
path: /api/health
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
*.log
.git
.gitignore
README.md
+37
View File
@@ -0,0 +1,37 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV MORPHOS_DATA_DIR=/app/data
CMD ["node", "dist/index.js"]
+52
View File
@@ -0,0 +1,52 @@
app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
description: MorphOS server platform. Decentralized application server.
container:
image: archipelago/morphos-server:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: morphos-server
ports:
- host: 8089
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/morphos-server
target: /app/data
options: [rw]
environment:
- MORPHOS_ENV=production
- MORPHOS_DATA_DIR=/app/data
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "morphos-server",
"version": "1.0.0",
"description": "MorphOS server platform",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
+27
View File
@@ -0,0 +1,27 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' });
});
// API endpoints
app.get('/api/info', (req, res) => {
res.json({
name: 'MorphOS Server',
version: '1.0.0',
status: 'running'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`MorphOS Server listening on port ${port}`);
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`);
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+77
View File
@@ -0,0 +1,77 @@
app:
id: netbird-dashboard
name: NetBird Dashboard
version: "2.38.0"
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
category: networking
# Hyphen name matches runtime references + the live container (adoption).
# Alias `netbird-dashboard` is the short hostname the proxy's nginx proxies to.
container_name: netbird-dashboard
container:
image: docker.io/netbirdio/dashboard:v2.38.0
pull_policy: if-not-present
network: netbird-net
network_aliases: [netbird-dashboard]
# The dashboard SPA bakes its API/OIDC base URL from these at container
# start. They must point at the proxy's public HTTPS origin (8087) so the
# browser uses a secure context (window.crypto.subtle / OIDC PKCE, #15).
# {{HOST_IP}} is the node's primary host IP, resolved at apply time.
derived_env:
- key: NETBIRD_MGMT_API_ENDPOINT
template: "https://{{HOST_IP}}:8087"
- key: NETBIRD_MGMT_GRPC_API_ENDPOINT
template: "https://{{HOST_IP}}:8087"
- key: AUTH_AUTHORITY
template: "https://{{HOST_IP}}:8087/oauth2"
dependencies:
- app_id: netbird-server
resources:
memory_limit: 256Mi
security:
# cap-drop=ALL is applied by the orchestrator. The dashboard image runs
# nginx (master as root, drops workers) binding :80 — needs the worker-drop
# caps + NET_BIND_SERVICE for the privileged port.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
# Internal only — reached container-to-container by the proxy via netbird-net.
ports: []
volumes: []
environment:
- AUTH_AUDIENCE=netbird-dashboard
- AUTH_CLIENT_ID=netbird-dashboard
- AUTH_CLIENT_SECRET=
- USE_AUTH0=false
- AUTH_SUPPORTED_SCOPES=openid profile email groups
- AUTH_REDIRECT_URI=/nb-auth
- AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
- NETBIRD_TOKEN_SOURCE=idToken
- NGINX_SSL_PORT=443
- LETSENCRYPT_DOMAIN=none
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
metadata:
author: NetBird
icon: /assets/img/app-icons/netbird.svg
website: https://netbird.io
repo: https://github.com/netbirdio/dashboard
license: BSD-3-Clause
tags:
- networking
- vpn
- dashboard
+130
View File
@@ -0,0 +1,130 @@
app:
id: netbird-server
name: NetBird Server
version: "0.71.2"
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
category: networking
# Hyphen name matches the runtime references (crash_recovery / dependencies /
# config startup order) + the live container, so on an existing node the
# orchestrator ADOPTS the running server rather than recreating it (data +
# the sqlite store under /var/lib/netbird preserved). Alias `netbird-server`
# is the short hostname the proxy's nginx proxies/grpc-passes to.
container_name: netbird-server
container:
image: docker.io/netbirdio/netbird-server:0.71.2
pull_policy: if-not-present
network: netbird-net
network_aliases: [netbird-server]
# The relay authSecret and the sqlite store encryptionKey are base64 keys
# (the server base64-decodes them to recover raw bytes — hex would decode to
# the wrong value). Generated once and reused: ensure_generated_secrets
# no-ops when the file already exists, so a re-render of config.yaml on an
# adopted node keeps the same keys (regenerating would orphan the store).
generated_secrets:
- name: netbird-relay-auth-secret
kind: base64
- name: netbird-store-encryption-key
kind: base64
# Pass the rendered config explicitly, mirroring the legacy `--config` arg.
custom_args: ["--config", "/etc/netbird/config.yaml"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 1Gi
security:
# cap-drop=ALL is applied by the orchestrator. The server binds :80
# (management/signal/relay HTTP + gRPC) inside the container — a privileged
# port — so it needs NET_BIND_SERVICE. STUN is 3478/udp (unprivileged).
capabilities: [NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8086
container: 80
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
auth: none
auth_rationale: >-
NetBird management API and its OIDC issuer. Enrolled devices authenticate
themselves with setup keys and JWTs, and they cannot hold a browser session —
a login page here would disconnect every VPN client on the network.
- host: 3478
container: 3478
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
auth: none
auth_rationale: >-
STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all.
volumes:
- type: bind
source: /var/lib/archipelago/netbird/data
target: /var/lib/netbird
options: [rw]
# The rendered config.yaml, read-only. Re-rendered on every reconcile from
# host facts + the base64 secrets; idempotent (stable bytes → no restart).
- type: bind
source: /var/lib/archipelago/netbird/config.yaml
target: /etc/netbird/config.yaml
options: [ro]
environment: []
# The server's config. {{HOST_IP}} is the node's primary host IP (the proxy's
# public origin is https on 8087 — the dashboard needs a secure context for
# OIDC PKCE, issue #15). {{secret:...}} are read 0600 from the secrets dir.
files:
- path: /var/lib/archipelago/netbird/config.yaml
overwrite: true
content: |
server:
listenAddress: ":80"
exposedAddress: "https://{{HOST_IP}}:8087"
stunPorts:
- 3478
metricsPort: 9090
healthcheckAddress: ":9000"
logLevel: "info"
logFile: "console"
authSecret: "{{secret:netbird-relay-auth-secret}}"
dataDir: "/var/lib/netbird"
auth:
issuer: "https://{{HOST_IP}}:8087/oauth2"
localAuthDisabled: false
signKeyRefreshEnabled: false
dashboardRedirectURIs:
- "https://{{HOST_IP}}:8087/nb-auth"
- "https://{{HOST_IP}}:8087/nb-silent-auth"
dashboardPostLogoutRedirectURIs:
- "https://{{HOST_IP}}:8087/"
cliRedirectURIs:
- "http://localhost:53000/"
store:
engine: "sqlite"
encryptionKey: "{{secret:netbird-store-encryption-key}}"
# TCP liveness on the management port. Binds at startup, stays green; an http
# check of /oauth2 would false-fail while the issuer warms up.
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 10
start_period: 30s
metadata:
author: NetBird
icon: /assets/img/app-icons/netbird.svg
website: https://netbird.io
repo: https://github.com/netbirdio/netbird
license: BSD-3-Clause
tags:
- networking
- vpn
- wireguard
- mesh
+187
View File
@@ -0,0 +1,187 @@
app:
id: netbird
name: NetBird
version: "2.38.0"
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
category: networking
# The user-facing launcher (app_id + container both "netbird", matching the
# runtime references + the live container so the orchestrator adopts it). This
# is the nginx that terminates TLS on 8087 and fans out to the dashboard +
# server by their short aliases on netbird-net.
container_name: netbird
container:
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a
# secure context (window.crypto.subtle / OIDC PKCE, issue #15), so the proxy
# serves HTTPS. Idempotent: kept as-is when crt+key already exist (a user
# accepts it once). SAN defaults to the host IP + 127.0.0.1 + localhost.
generated_certs:
- crt: /var/lib/archipelago/netbird/tls.crt
key: /var/lib/archipelago/netbird/tls.key
dependencies:
- app_id: netbird-server
- app_id: netbird-dashboard
- storage: 1Gi
resources:
memory_limit: 256Mi
security:
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
# workers) binds :443 — needs the worker-drop caps + NET_BIND_SERVICE.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
# 8087 publishes the TLS listener (container :443). HTTPS is required for the
# dashboard's secure context (issue #15).
- host: 8087
container: 443
protocol: tcp
auth: none
auth_rationale: >-
NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP,
so fronting this port would break the secure context the dashboard requires
(issue #15) and the certificate clients pin.
volumes:
- type: bind
source: /var/lib/archipelago/netbird/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
- type: bind
source: /var/lib/archipelago/netbird/tls.crt
target: /etc/nginx/tls.crt
options: [ro]
- type: bind
source: /var/lib/archipelago/netbird/tls.key
target: /etc/nginx/tls.key
options: [ro]
environment: []
# The proxy config. {{NETWORK_GATEWAY}} is the netbird-net bridge gateway =
# Podman's aardvark DNS. nginx uses it as an explicit `resolver` with VARIABLE
# upstreams so it re-resolves container names per request — without it nginx
# pins a container IP at startup and 502s forever once that IP moves on a
# restart/reboot (issue #15, observed live on .198). Every #15 fix below
# (CORS $http_origin reflect, grpc pass, nb-auth/nb-silent-auth rewrite to
# index.html, /relay websocket) is preserved verbatim from the legacy config.
files:
- path: /var/lib/archipelago/netbird/nginx.conf
overwrite: true
content: |
server {
listen 443 ssl;
server_name _;
# netbird's dashboard needs a secure context (window.crypto.subtle for
# OIDC PKCE), so the proxy terminates TLS with a self-signed cert (#15).
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
# Rootless Podman can hand a container a new IP across restarts/reboots.
# nginx resolves a literal upstream name ONCE at startup and caches it,
# so after the IP moves every request 502s with "host unreachable"
# (issue #15, observed live on .198: nginx pinned to a dead
# netbird-dashboard IP). Fix: point `resolver` at the netbird-net
# gateway (Podman's aardvark DNS) and use VARIABLE upstreams, which
# forces nginx to re-resolve the container names at request time.
resolver {{NETWORK_GATEWAY}} valid=10s ipv6=off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
location ~ ^/(relay|ws-proxy/) {
set $nb_server netbird-server;
proxy_pass http://$nb_server:80;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}
location ~ ^/(api|oauth2)(/|$) {
# The dashboard is a SPA whose API/OIDC base URL is baked at build
# time to one host:port. A single box is reached via several
# addresses, so those fetches are cross-origin and the browser
# blocks them with no Access-Control-Allow-Origin (#15, live on
# .198). Reflect the caller's Origin and answer the CORS preflight.
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
set $nb_server netbird-server;
proxy_pass http://$nb_server:80;
}
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {
set $nb_server netbird-server;
grpc_pass grpc://$nb_server:80;
grpc_read_timeout 1d;
grpc_send_timeout 1d;
}
# OIDC callback routes are client-side SPA routes with NO prebuilt page
# in the dashboard bundle, so proxying them straight through 404s —
# which crashes the dashboard's auth init and shows "Unauthenticated"
# with dead buttons (#15, live on .198: /nb-auth + /nb-silent-auth
# returned 404). Serve index.html at these paths (URL unchanged) so
# react-oidc boots and completes the login / silent-SSO.
location ~ ^/(nb-auth|nb-silent-auth) {
set $nb_dashboard netbird-dashboard;
rewrite ^.*$ /index.html break;
proxy_pass http://$nb_dashboard:80;
}
location / {
set $nb_dashboard netbird-dashboard;
proxy_pass http://$nb_dashboard:80;
}
}
health_check:
type: tcp
endpoint: localhost:443
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
interfaces:
main:
name: Dashboard
description: Manage your self-hosted NetBird mesh VPN
type: ui
port: 8087
protocol: https
path: /
metadata:
author: NetBird
icon: /assets/img/app-icons/netbird.svg
website: https://netbird.io
repo: https://github.com/netbirdio/netbird
license: BSD-3-Clause
tags:
- networking
- vpn
- wireguard
- mesh
+61
View File
@@ -0,0 +1,61 @@
app:
id: nextcloud
name: Nextcloud
version: "29"
description: Your own private cloud. File sync, calendars, contacts.
container:
image: 146.59.87.168:3000/lfg2025/nextcloud:29
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8085
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/nextcloud
target: /var/www/html
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Nextcloud file and collaboration dashboard
type: ui
port: 8085
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/nextcloud.webp
category: data
author: Nextcloud
repo: https://github.com/nextcloud/server
launch:
open_in_new_tab: true
+5
View File
@@ -0,0 +1,5 @@
# Nostr RS Relay - uses official image
FROM scsibug/nostr-rs-relay:latest
# Default configuration is in the image
# No additional setup needed
+60
View File
@@ -0,0 +1,60 @@
app:
id: nostr-rs-relay
name: Nostr Relay (Rust)
version: 0.8.0
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container:
image: scsibug/nostr-rs-relay:0.8.9
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
dependencies:
- storage: 10Gi # For event storage
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: nostr-relay
ports:
- host: 18081
container: 8080
protocol: tcp # HTTP/WebSocket
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/nostr-relay
target: /usr/src/app/db
options: [rw]
environment:
- RELAY_NAME=Archipelago Nostr Relay
- RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago
- MAX_EVENTS=1000000
- MAX_SUBSCRIPTIONS=100
health_check:
type: http
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 30s
retries: 5
nostr_integration:
relay_type: public
monetization_enabled: true # Earn networking profits
event_storage: sqlite
+62
View File
@@ -0,0 +1,62 @@
app:
id: photoprism
name: PhotoPrism
version: "240915"
description: AI-powered photo management with facial recognition.
container:
image: 146.59.87.168:3000/lfg2025/photoprism:240915
pull_policy: if-not-present
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, SETUID, SETGID]
readonly_root: false
network_policy: isolated
ports:
- host: 2342
container: 2342
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/photoprism
target: /photoprism/storage
options: [rw]
environment:
- PHOTOPRISM_ADMIN_PASSWORD=archipelago
- PHOTOPRISM_DEFAULT_LOCALE=en
health_check:
type: tcp
endpoint: localhost:2342
interval: 60s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: PhotoPrism photo library
type: ui
port: 2342
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/photoprism.svg
category: data
author: PhotoPrism
repo: https://github.com/photoprism/photoprism
launch:
open_in_new_tab: true
+73
View File
@@ -0,0 +1,73 @@
app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) so the orchestrator adopts a matching running container instead of
# recreating it.
container_name: pine-openwakeword
container:
image: docker.io/rhasspy/wyoming-openwakeword:2.1.0
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-openwakeword]
# The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock
# "ok nabu" model; /custom is where a trained custom model (yo_archy)
# drops in later — the engine picks up new .tflite files on restart.
custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"]
dependencies:
- storage: 512Mi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10400 (the Wyoming integration endpoint).
- host: 10400
container: 10400
protocol: tcp
auth: none
auth_rationale: >-
Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.
volumes:
- type: bind
source: /var/lib/archipelago/pine-openwakeword
target: /custom
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10400
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-openwakeword
repo: https://github.com/rhasspy/wyoming-openwakeword
license: MIT
tags:
- home
- voice
- wake-word
- wyoming
+73
View File
@@ -0,0 +1,73 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.2.2"
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded voices under /data
# preserved).
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
# picks the voice (mirrors the pine ha-stack.yml compose command).
custom_args: ["--voice", "en_GB-alba-medium"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the voice into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10200 (the Wyoming integration endpoint).
- host: 10200
container: 10200
protocol: tcp
auth: none
auth_rationale: >-
Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.
volumes:
- type: bind
source: /var/lib/archipelago/pine-piper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10200
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the voice
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-piper
repo: https://github.com/rhasspy/wyoming-piper
license: MIT
tags:
- home
- voice
- text-to-speech
- wyoming
+81
View File
@@ -0,0 +1,81 @@
app:
id: pine-whisper
name: Pine Whisper (STT)
# App revision 3.4.2 = upstream wyoming-whisper 3.4.1 image + tuned args
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
# pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2"
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded models under /data
# preserved).
container_name: pine-whisper
container:
image: docker.io/rhasspy/wyoming-whisper:3.4.1
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-whisper]
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
# pick the model + language (mirrors the pine ha-stack.yml compose command).
# --beam-size 1: the image default is 5 on x86 (1 on ARM). Benchmarked on
# framework-pt (i5-1135G7, base-int8): beam 1 transcribes the same text
# ~45% faster — the standard low-latency setting for short voice commands
# (HA's own whisper add-on defaults to 1).
custom_args: ["--model", "base-int8", "--language", "en", "--beam-size", "1"]
dependencies:
- storage: 2Gi
resources:
memory_limit: 2Gi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the whisper model into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10300 (the Wyoming integration endpoint).
- host: 10300
container: 10300
protocol: tcp
auth: none
auth_rationale: >-
Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.
volumes:
- type: bind
source: /var/lib/archipelago/pine-whisper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10300
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the model
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-faster-whisper
repo: https://github.com/rhasspy/wyoming-faster-whisper
license: MIT
tags:
- home
- voice
- speech-to-text
- wyoming
+402
View File
@@ -0,0 +1,402 @@
app:
id: pine
name: Pine
version: "1.3.0"
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home
# The user-facing launcher (app_id + container both "pine", matching the
# runtime references + the live container so the orchestrator adopts it). A
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
# stack members.
container_name: pine
container:
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
# (https or localhost), so the launcher terminates TLS with a self-signed
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
# secure-context fix (#15).
generated_certs:
- crt: /var/lib/archipelago/pine/tls.crt
key: /var/lib/archipelago/pine/tls.key
dependencies:
- app_id: pine-whisper
- app_id: pine-piper
- app_id: pine-openwakeword
- storage: 128Mi
resources:
memory_limit: 64Mi
security:
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
# workers) binds :443 inside the container — needs the worker-drop caps +
# NET_BIND_SERVICE for the privileged port.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
# 10380 (http) is the Open target — the UI launches apps as
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
# listener on 10381, so the new tab lands on a secure context where
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
- host: 10380
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
- host: 10381
container: 443
protocol: tcp
auth: none
auth_rationale: >-
Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would
break the secure context navigator.bluetooth needs for WiFi provisioning.
The plain-HTTP entry point (10380) is gated, and it is what the UI opens.
volumes:
- type: bind
source: /var/lib/archipelago/pine/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/tls.crt
target: /etc/nginx/tls.crt
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/tls.key
target: /etc/nginx/tls.key
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/index.html
target: /usr/share/nginx/html/index.html
options: [ro]
environment: []
files:
- path: /var/lib/archipelago/pine/nginx.conf
overwrite: true
content: |
server {
listen 80;
server_name _;
return 301 https://$host:10381$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
root /usr/share/nginx/html;
index index.html;
# Live node facts for the status card — proxied to the node's
# public status tier so the (https) page can fetch same-origin.
location = /node-status {
proxy_pass http://host.containers.internal:80/api/pine/status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
}
location / { try_files $uri $uri/ /index.html; }
}
- path: /var/lib/archipelago/pine/index.html
overwrite: true
content: |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pine — connect your speaker</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
background: #0f1512; color: #e7efe9; }
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
h1 { font-size: 26px; margin: 0; }
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
.card { background: #16201b; border: 1px solid #24332b;
border-radius: 14px; padding: 20px; margin: 20px 0; }
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
.status .row b { min-width: 84px; color: #9fd3b6; }
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
font-size: 13px; color: #cfe9d9; }
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
outline: none; }
input:focus { border-color: #4fae82; }
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
border-radius: 10px; cursor: pointer; transition: filter .15s; }
button:hover:not(:disabled) { filter: brightness(1.08); }
button:disabled { opacity: .45; cursor: default; }
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
font-family: ui-monospace, monospace; white-space: pre-wrap;
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
.ha b { color: #9fbfad; }
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
color: #e8c878; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
<rect x="236" y="396" width="40" height="72" rx="6"/>
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g></svg>
<div><h1>Pine</h1>
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
</header>
<div class="card status">
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
</div>
<div class="card status">
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
</div>
<div class="card insecure" id="insecure">
This page isnt running over HTTPS, so the browser blocks Bluetooth.
Open it via its <b>https://…:10380</b> address (accept the self-signed
certificate) and the button below will work.
</div>
<div class="card">
<label for="ssid">WiFi network (SSID)</label>
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
<input id="pass" type="password" autocomplete="off">
<button id="go">Connect Pine to WiFi</button>
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
</div>
<p class="ha">After WiFi joins, one manual step remains — pair the
speaker in Home Assistant: <b>Settings → Devices &amp; services →
Add Wyoming Protocol</b>, host = the speakers IP, port
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
are wired up automatically when Pine installs. Wake word:
<b>“Hey Jarvis.”</b> Ask node things like <i>“whats the block
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
synced?”</i> or <i>“whats my lightning balance?”</i> — and when a
Claude API key is set on the node, anything else gets answered by
Claude. New mesh messages are announced on the speaker too.</p>
<p class="ha">Troubleshooting: if it hears you (LED reacts) but answers
are silent, unplug and replug the speaker — an interrupted answer can
wedge its audio output until it reboots.</p>
</div>
<script>
"use strict";
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
const SVC = "00467768-6228-2272-4663-277478268000";
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
4:"not authorized — press the button on the device",5:"bad hostname",
255:"unknown device error"};
const logEl = document.getElementById("log");
const log = (msg, cls) => { const l = document.createElement("div");
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
logEl.scrollTop = logEl.scrollHeight; };
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
.map(b => b.toString(16).padStart(2, "0")).join(" ");
const btn = document.getElementById("go");
if (!navigator.bluetooth) {
document.getElementById("insecure").style.display = "block";
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
}
btn.addEventListener("click", async () => {
const ssid = document.getElementById("ssid").value.trim();
const pass = document.getElementById("pass").value;
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
btn.disabled = true; logEl.textContent = "";
let device;
try {
log("Opening Bluetooth device chooser…");
device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
optionalServices: [SVC],
});
log(`Selected: ${device.name || "(unnamed)"}`);
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
log("Connecting…");
const gatt = await device.gatt.connect();
const svc = await gatt.getPrimaryService(SVC);
const stateChar = await svc.getCharacteristic(CHAR_STATE);
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
let done, fail;
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
let authorize; const authorized = new Promise(res => { authorize = res; });
let state = -1;
const onState = (s, via = "") => {
if (s === state) return; state = s;
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
if (s >= 2) authorize();
if (s === 4) done(null);
};
stateChar.addEventListener("characteristicvaluechanged",
e => onState(e.target.value.getUint8(0)));
errorChar.addEventListener("characteristicvaluechanged", e => {
const c = e.target.value.getUint8(0);
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
resultChar.addEventListener("characteristicvaluechanged", e => {
const v = e.target.value; log(`RPC result: ${hex(v)}`);
if (v.byteLength > 2 && v.getUint8(1) > 0) {
const n = v.getUint8(2); const b = new Uint8Array(n);
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
done(new TextDecoder().decode(b)); } });
await stateChar.startNotifications();
await errorChar.startNotifications();
await resultChar.startNotifications();
onState((await stateChar.readValue()).getUint8(0));
const poller = setInterval(async () => {
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
const ec = (await errorChar.readValue()).getUint8(0);
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
let nextUrl;
try {
if (state === 1) {
log("Authorization required — press the centre button on the speaker now.", "warn");
await Promise.race([authorized, outcome,
new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out waiting for the button press")), 60000))]);
log("Authorized.", "ok");
}
const enc = new TextEncoder();
const sb = enc.encode(ssid), pb = enc.encode(pass);
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
const pkt = new Uint8Array([1, data.length, ...data, 0]);
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
log(`Sending WiFi credentials for “${ssid}”…`);
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
else await rpcChar.writeValue(pkt);
log("Credentials delivered — speaker acknowledged.", "ok");
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
const timeout = new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
nextUrl = await Promise.race([outcome, timeout]);
} finally { clearInterval(poller); }
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
try { gatt.disconnect(); } catch {}
} catch (err) {
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
if (err.name === "NotFoundError")
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
+ "Hold the dot button 15s to reset.", "warn");
if (err.name === "NetworkError")
log("BLE link dropped — move closer, and make sure the Mac isn't already "
+ "paired to the speaker (it can hold the link exclusively).", "warn");
try { device?.gatt?.disconnect(); } catch {}
} finally { btn.disabled = false; }
});
// Live node status card — public tier of /api/pine/status via the
// same-origin /node-status proxy. Best-effort: failures just show
// "unavailable" and retry on the next tick.
const nsNode = document.getElementById("ns-node");
const nsBtc = document.getElementById("ns-btc");
const nsPeers = document.getElementById("ns-peers");
async function refreshNodeStatus() {
try {
const r = await fetch("/node-status", { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
const s = await r.json();
const up = Math.floor((s.uptime_seconds || 0) / 3600);
nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`;
if (s.bitcoin && s.bitcoin.height != null) {
const pct = s.bitcoin.sync_percent;
nsBtc.textContent = `block ${s.bitcoin.height}` +
(pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : "");
} else {
nsBtc.textContent = "not running";
}
const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?";
const meshPeers = s.mesh ? s.mesh.peers : 0;
nsPeers.textContent = `${btcPeers} bitcoin` +
(s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : "");
} catch {
nsNode.textContent = "node status unavailable";
nsBtc.textContent = "—";
nsPeers.textContent = "—";
}
}
refreshNodeStatus();
setInterval(refreshNodeStatus, 30000);
</script>
</body>
</html>
health_check:
type: tcp
endpoint: localhost:443
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
interfaces:
main:
name: Pine
description: Connect your speaker to WiFi and check the voice assistant
type: ui
port: 10380
protocol: http
path: /
metadata:
author: Archipelago
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming
repo: https://github.com/rhasspy/wyoming
license: MIT
category: home
launch:
open_in_new_tab: true
tags:
- home
- voice
- assistant
- privacy
+125
View File
@@ -0,0 +1,125 @@
app:
id: podsteadr-blossom
name: podsteadr Blossom
version: "4"
description: Blossom (BUD-02) sha256-addressed media blob server backing podsteadr's episode uploads and covers.
category: media
# Hyphenated name matches the podsteadr repo's docker-compose container_name
# (podsteadr-blossom); alias `blossom` is the short hostname podsteadr's
# server reaches it by (BLOSSOM_URL_INTERNAL=http://blossom:3000).
container_name: podsteadr-blossom
container:
image: ghcr.io/hzrd149/blossom-server:4
pull_policy: if-not-present
network: podsteadr-net
network_aliases: [blossom]
# Image runs as container-root (no USER directive) writing to a
# bind-mounted /app/data — CHOWN/DAC_OVERRIDE cover the fresh-bind-dir
# ownership gap the same way apps/botfights and apps/immich document.
# Unverified against a real install; check first-boot logs.
data_uid: "0:0"
dependencies:
- storage: 20Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 20Gi
security:
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 8098
container: 3000
protocol: tcp
auth: none
auth_rationale: >-
Media blobs (episode audio/video, covers) must be publicly fetchable
by podcast clients as RSS enclosure URLs — that's the entire purpose
of this port. Uploads are separately gated by blossom's own BUD-02
signed-nostr-event auth (upload.requireAuth below), not a node
session; reads are intentionally public per the config's own header
comment.
volumes:
- type: bind
source: /var/lib/archipelago/podsteadr-blossom/data
target: /app/data
options: [rw]
- type: bind
source: /var/lib/archipelago/podsteadr-blossom/config/config.yml
target: /app/config.yml
options: [ro]
environment: []
files:
- path: /var/lib/archipelago/podsteadr-blossom/config/config.yml
overwrite: true
content: |
# blossom-server (v4.x) configuration for podsteadr.
# Uploads require a signed nostr auth event (BUD-02, kind 24242);
# reads are public so podcast apps can fetch enclosures.
#
# NOTE (blossom-server 4.4.1 gotcha, do not rediscover): `rules:` MUST
# be nested under `storage:` — a top-level `rules:` key is silently
# ignored, the ruleset ends up empty, and every upload fails 401
# "Server dose not accept video/mp4 blobs" (typo is theirs). The
# GitHub master branch is a Deno rewrite with a different schema
# (storage.rules, BUD-11, range support); the `:4` image is the older
# node/koa codebase this config targets.
publicDomain: ""
databasePath: data/sqlite.db
dashboard:
enabled: false
discovery:
nostr:
enabled: false
relays: []
upstream:
enabled: false
domains: []
storage:
backend: local
local:
dir: ./data/blobs
removeWhenNoOwners: false
# "expiration" is time since a blob was last accessed — unaccessed
# blobs get pruned after this. Podcast media should effectively
# never expire, so keep this long.
rules:
- type: "*"
expiration: 10 years
upload:
enabled: true
requireAuth: true
requirePubkeyInRule: false
list:
requireAuth: false
allowListOthers: true
tor:
enabled: false
proxy: ""
health_check:
# No documented health endpoint; TCP liveness on the app port.
type: tcp
endpoint: localhost:3000
interval: 30s
timeout: 5s
retries: 3
+164
View File
@@ -0,0 +1,164 @@
app:
id: podsteadr-mediamtx
name: podsteadr MediaMTX
version: "1.20.0"
description: MediaMTX ingest/output backend for podsteadr — RTMP + WebRTC/WHIP ingest, HLS playback, stream recording.
category: media
# Hyphenated name matches the podsteadr repo's docker-compose container_name
# (podsteadr-mediamtx); alias `mediamtx` is the short hostname podsteadr's
# server reaches it by (MEDIAMTX_API_URL=http://mediamtx:9997) and the one
# baked into mediamtx.yml's authHTTPAddress callback below.
container_name: podsteadr-mediamtx
container:
image: docker.io/bluenviron/mediamtx:1.20.0
pull_policy: if-not-present
network: podsteadr-net
network_aliases: [mediamtx]
derived_env:
# Browsers need a reachable ICE host candidate for WebRTC/WHIP; without
# this, the offer only advertises container-internal addresses and
# publish/playback negotiation fails for anyone off-host.
- key: MTX_WEBRTCADDITIONALHOSTS
template: "{{HOST_MDNS}}"
dependencies:
- storage: 10Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 10Gi
security:
# Stock mediamtx image runs as container-root (no USER directive) but
# only ever writes to the bind-mounted /recordings — CHOWN/DAC_OVERRIDE
# cover the fresh-bind-dir-ownership gap the same way apps/botfights and
# apps/immich document (root uid inside the container does not
# automatically bypass DAC checks once cap-drop ALL applies). Unverified
# against a real install; check first-boot logs on initial deploy.
capabilities: [CHOWN, DAC_OVERRIDE]
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
- host: 1935
container: 1935
protocol: tcp
auth: none
auth_rationale: >-
RTMP ingest (OBS). Not HTTP, so the node's session gate has no login
page to serve here; publish auth is delegated to podsteadr's own
HTTP auth webhook (authHTTPAddress below), which checks a per-stream
secret key never exposed in this port mapping.
- host: 8889
container: 8889
protocol: tcp
auth: none
auth_rationale: >-
WebRTC/WHIP ingest — browsers publish directly with a per-stream
bearer secret checked by podsteadr's auth webhook, the same
protocol-level auth as the RTMP port above.
- host: 8189
container: 8189
protocol: udp
auth: none
auth_rationale: >-
WebRTC ICE/UDP media transport. Raw UDP has no HTTP session concept
for the gate to enforce.
- host: 8890
container: 8888
protocol: tcp
auth: none
auth_rationale: >-
Public HLS playback URL, handed out to viewers and podcast/livestream
clients outside the node (zap.stream, third-party players). A login
page here would break every external viewer; playback is read-only.
volumes:
- type: bind
# Shared with apps/podsteadr (mounted read-only there) so the app can
# list and remux finished recordings for one-click episode publishing.
source: /var/lib/archipelago/podsteadr/recordings
target: /recordings
options: [rw]
- type: bind
source: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml
target: /mediamtx.yml
options: [ro]
environment: []
files:
- path: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml
overwrite: true
content: |
# MediaMTX configuration for podsteadr.
# Ingest: RTMP (OBS) + WebRTC/WHIP (browser). Output: HLS. Publish auth is
# delegated to podsteadr via HTTP; stream status is polled from the API.
logLevel: info
api: yes
apiAddress: :9997
# ---- authentication ------------------------------------------------------
authMethod: http
authHTTPAddress: http://podsteadr-app:8095/api/mediamtx/auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
# ---- protocols -----------------------------------------------------------
rtsp: no
srt: no
moq: no
rtmp: yes
rtmpAddress: :1935
hls: yes
hlsAddress: :8888
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
# instead of intermittent muxer crashes / viewer buffering.
hlsVariant: mpegts
hlsAlwaysRemux: yes
hlsAllowOrigins: ["*"]
webrtc: yes
webrtcAddress: :8889
webrtcLocalUDPAddress: :8189
webrtcAllowOrigins: ["*"]
# ---- recording -----------------------------------------------------------
pathDefaults:
record: yes
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
recordFormat: fmp4
recordPartDuration: 1s
recordSegmentDuration: 1h
recordDeleteAfter: 168h
paths:
# Streams live at live/<streamId>; publish requires the stream secret,
# which podsteadr checks in the auth webhook.
"~^live/[A-Za-z0-9]+$": {}
health_check:
# Stock mediamtx image has no shell, so an in-container HTTP probe of the
# API isn't meaningfully cheaper than TCP; RTMP liveness is enough (same
# polling-not-hooks rationale as podsteadr's own status poller, which
# exists precisely because runOn*-style shell hooks aren't available on
# this image).
type: tcp
endpoint: localhost:1935
interval: 30s
timeout: 5s
retries: 3
+44
View File
@@ -0,0 +1,44 @@
# Vendored copy of the podsteadr repo's own Dockerfile (source lives outside
# this tree — http://146.59.87.168:3000/ssmithx/podsteadr). Re-sync by hand if
# the upstream Dockerfile changes; build with build-from-prototype.sh, which
# passes the podsteadr repo root as build context (this Dockerfile expects
# frontend/ and server/ subdirectories at the context root, not this apps/
# directory).
#
# ---- frontend ----
FROM node:22-bookworm-slim AS frontend-build
WORKDIR /build/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ---- server ----
FROM node:22-bookworm-slim AS server-build
WORKDIR /build/server
COPY server/package*.json ./
RUN npm ci
COPY server/ ./
RUN npm run build && npm prune --omit=dev
# ---- runtime ----
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=server-build /build/server/node_modules ./node_modules
COPY --from=server-build /build/server/package.json ./package.json
COPY --from=server-build /build/server/dist ./dist
COPY --from=frontend-build /build/frontend/dist ./public
# Named volumes inherit ownership from the image path: keep /data writable by node
RUN mkdir -p /data && chown node:node /data
USER node
ENV NODE_ENV=production \
PORT=8095 \
DATA_DIR=/data \
STATIC_DIR=/app/public
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://localhost:8095/api/health || exit 1
CMD ["node", "dist/index.js"]
+85
View File
@@ -0,0 +1,85 @@
# podsteadr — Nostr-native Podcasting & Livestreaming
Self-hosted, nostr-native podcast publishing and livestreaming. Log in with a
NIP-07 nostr identity (no passwords, no email), upload an mp4 to publish an
RSS 2.0 feed with Podcasting 2.0 lightning payment info, or go live via OBS
(RTMP) or the browser (WebRTC/WHIP) — the stream is announced on nostr as a
NIP-53 live event and viewers watch over HLS.
This is a three-container stack:
| App | Manifest | Role |
|---|---|---|
| `podsteadr` | `apps/podsteadr/manifest.yml` | Fastify API + built Vue UI + RSS feeds |
| `podsteadr-mediamtx` | `apps/podsteadr-mediamtx/manifest.yml` | RTMP/WHIP ingest, HLS output, recording |
| `podsteadr-blossom` | `apps/podsteadr-blossom/manifest.yml` | BUD-02 sha256-addressed media blobs |
All three join a dedicated `podsteadr-net` bridge network and resolve each
other by short DNS aliases (`podsteadr-app`, `mediamtx`, `blossom`).
## Building the Image
The app image is built from the **podsteadr** repo, source of truth at
`http://146.59.87.168:3000/ssmithx/podsteadr`.
### Option 1: Use the build script
```bash
# From archy repo root
./apps/podsteadr/build-from-prototype.sh
```
### Option 2: Build from source directory
```bash
cd ~/podsteadr
podman build -t localhost/podsteadr:1.0.0 -f ~/archy/apps/podsteadr/Dockerfile .
```
### Publishing to the shared registry
```bash
./apps/podsteadr/push-to-registry.sh 1.0.0
```
Then update `apps/podsteadr/manifest.yml`'s `container.image` to the pushed
tag so other nodes pull instead of building locally.
## Ports
See `apps/PORTS.md`. Summary: 8095 (web UI/API/RSS), 1935 (RTMP), 8889
(WebRTC/WHIP), 8189/udp (WebRTC ICE), 8890 (HLS), 8098 (Blossom).
All of podsteadr's ports are `auth: none` — this is a public podcast/livestream
server, not a private personal app; RSS feeds, HLS playback, and blob reads
must stay reachable by third-party clients with no Archipelago session, and
the app enforces its own NIP-98 signed-request auth for sensitive routes and
per-stream secret keys for RTMP/WHIP publish. See the `auth_rationale` on each
port mapping.
## Nostr Identity
podsteadr's frontend vendors a copy of Archipelago's `nostr-provider.js` shim
and references it directly from `index.html` (its Fastify server isn't the
nginx-served SPA shape the platform auto-patches — see "Nostr Signer Bridge"
in `docs/app-developer-guide.md`). `apps/podsteadr/manifest.yml` declares a
`post_install` hook that re-copies the canonical
`/opt/archipelago/web-ui/nostr-provider.js` over the vendored copy on every
install/reinstall, so it doesn't go stale across OTA releases.
## Data
- `/var/lib/archipelago/podsteadr` — SQLite DB, server's own nostr key,
covers, and (read-only here) shared stream recordings.
- `/var/lib/archipelago/podsteadr/recordings` — stream recordings (writable
by `podsteadr-mediamtx`, read-only for `podsteadr`), 7-day retention.
- `/var/lib/archipelago/podsteadr-blossom/data` — media blobs.
## Known gotchas
See the podsteadr repo's `docs/STATUS.md` for the full list (blossom v4
config `rules:` nesting, no HTTP range support in blossom 4.x, split-horizon
blossom URL, MediaMTX has no shell so status is polled not hooked, standard
vs. low-latency HLS). The blossom and mediamtx config files embedded in
`apps/podsteadr-blossom/manifest.yml` / `apps/podsteadr-mediamtx/manifest.yml`
already carry the load-bearing ones inline as comments.
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Build the podsteadr container image from the podsteadr repo.
# Usage: ./build-from-prototype.sh [path-to-podsteadr-repo]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_REPO="$HOME/podsteadr"
REPO_DIR="${1:-$DEFAULT_REPO}"
IMAGE_TAG="localhost/podsteadr:1.0.0"
if [ ! -d "$REPO_DIR" ]; then
echo "podsteadr repo not found at: $REPO_DIR"
echo " Set path: $0 /path/to/podsteadr"
exit 1
fi
if [ ! -f "$REPO_DIR/server/package.json" ] || [ ! -f "$REPO_DIR/frontend/package.json" ]; then
echo "No server/package.json or frontend/package.json found in $REPO_DIR — is this the right directory?"
exit 1
fi
# Determine container runtime
RUNTIME="podman"
if ! command -v podman >/dev/null 2>&1; then
RUNTIME="docker"
fi
echo "Building podsteadr from $REPO_DIR using $SCRIPT_DIR/Dockerfile"
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$REPO_DIR"
echo "Built $IMAGE_TAG"
echo ""
echo "You can now install podsteadr from the App Store in Archipelago."
echo "Or run directly: $RUNTIME run -d --name podsteadr-app -p 8095:8095 $IMAGE_TAG"
+143
View File
@@ -0,0 +1,143 @@
app:
id: podsteadr
name: podsteadr
version: "1.0.0"
description: Self-hosted, nostr-native podcast publishing and livestreaming. Log in with Nostr, upload episodes or go live via OBS/WebRTC, publish to RSS with Podcasting 2.0 lightning payments.
category: media
# Container/DNS-alias name deliberately NOT "podsteadr" — on a host whose own
# hostname happens to be "podsteadr", the host's own /etc/hosts self-hostname
# entry (127.0.1.1, e.g. from cloud-init) shadows the container network's DNS
# alias for other containers looking up "podsteadr", and mediamtx's auth-webhook
# callback resolves to the host's loopback instead of this container — every
# RTMP publish gets rejected with "connection refused" (observed on
# podsteadr.atobitcoin.io, 2026-07-30; see docker-compose.yml in the podsteadr
# repo for the original writeup). Carried forward unchanged into the manifest.
container_name: podsteadr-app
container:
# Built locally from the podsteadr repo (source lives outside this tree —
# see apps/podsteadr/README.md + build-from-prototype.sh), same pattern as
# apps/indeedhub. Not yet pushed to the shared registry; push-to-registry.sh
# is there for when fleet-wide install is needed.
image: localhost/podsteadr:1.0.0
pull_policy: if-not-present
network: podsteadr-net
network_aliases: [podsteadr-app]
derived_env:
- key: PUBLIC_URL
template: "http://{{HOST_MDNS}}:8095"
- key: MEDIAMTX_RTMP_PUBLIC
template: "rtmp://{{HOST_MDNS}}:1935"
- key: MEDIAMTX_WHIP_PUBLIC
template: "http://{{HOST_MDNS}}:8889"
- key: MEDIAMTX_HLS_PUBLIC
template: "http://{{HOST_MDNS}}:8890"
- key: BLOSSOM_URL_DEFAULT
template: "http://{{HOST_MDNS}}:8098"
# node:22-bookworm-slim's built-in `node` user is uid:gid 1000:1000. The
# image's own Dockerfile chowns /data to node:node, but that only affects
# the image layer — the actual runtime mount is the bind volume below, so
# the host directory needs the same ownership or the read-only-root,
# non-root `node` process can't open the SQLite DB (unverified against a
# real node install; flagging per this repo's convention of documenting
# bind-mount ownership assumptions, e.g. apps/botfights/manifest.yml).
data_uid: "1000:1000"
dependencies:
- app_id: podsteadr-mediamtx
- app_id: podsteadr-blossom
- storage: 2Gi
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
- host: 8095
container: 8095
protocol: tcp
auth: none
auth_rationale: >-
podsteadr is a public podcast/livestream server: RSS feeds and the
marketplace/catalog API must stay fetchable by third-party podcast
clients, crawlers, and other podsteadr instances with no Archipelago
session, and the app already gates its own sensitive routes with
NIP-98 signed-request auth (see server/src/plugins/nostr-auth.ts in
the podsteadr repo). Putting the node's session gate in front would
block every external RSS/API consumer without adding real protection.
volumes:
- type: bind
source: /var/lib/archipelago/podsteadr
target: /data
options: [rw]
# Shares podsteadr-mediamtx's recordings directory (rw there, ro here) so
# the app can list/remux finished recordings for one-click episode
# publishing without granting it write access to live segments.
- type: bind
source: /var/lib/archipelago/podsteadr/recordings
target: /recordings
options: [ro]
environment:
- NODE_ENV=production
- PORT=8095
- DATA_DIR=/data
- RECORDINGS_DIR=/recordings
- MEDIAMTX_API_URL=http://mediamtx:9997
- BLOSSOM_URL_INTERNAL=http://blossom:3000
- NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
- CASHU_MINT_URL_DEFAULT=https://mint.minibits.cash/Bitcoin
# podsteadr's Fastify server (fastify-static) isn't the nginx-served SPA
# shape the platform auto-patches for NIP-07 injection (see "Nostr Signer
# Bridge" in docs/app-developer-guide.md) — its frontend already
# self-references /nostr-provider.js from index.html and vendors a copy at
# build time (podsteadr commit 133558d). That vendored copy goes stale
# across archy OTA releases, so re-copy the canonical host script over it
# on every install/reinstall instead of trusting the baked-in one.
hooks:
post_install:
- copy_from_host:
src: "web-ui/nostr-provider.js"
dest: /app/public/nostr-provider.js
health_check:
type: http
endpoint: http://localhost:8095
path: /api/health
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Podcast dashboard, upload/live wizard, and stream management
type: ui
port: 8095
protocol: http
path: /
metadata:
author: podsteadr
icon: /assets/img/app-icons/podsteadr.png
repo: http://146.59.87.168:3000/ssmithx/podsteadr
license: MIT
tags:
- nostr
- podcast
- livestream
- media
- rss
- lightning
launch:
open_in_new_tab: false
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Build and push the podsteadr container image to a registry.
# Usage: ./push-to-registry.sh [version]
#
# Environment variables:
# REGISTRY - Registry host (default: 146.59.87.168:3000, same as indeedhub/botfights)
# NAMESPACE - Registry namespace (default: lfg2025)
# RUNTIME - Container runtime (default: podman)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="${PODSTEADR_REPO:-$HOME/podsteadr}"
VERSION="${1:-1.0.0}"
REGISTRY="${REGISTRY:-146.59.87.168:3000}"
NAMESPACE="${NAMESPACE:-lfg2025}"
IMAGE_NAME="podsteadr"
RUNTIME="${RUNTIME:-podman}"
FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}"
if [ ! -d "$REPO_DIR" ]; then
echo "podsteadr repo not found at: $REPO_DIR"
echo "Set PODSTEADR_REPO=/path/to/podsteadr"
exit 1
fi
echo "=== podsteadr Container Registry Push ==="
echo "Source: $REPO_DIR"
echo "Image: $FULL_TAG"
echo "Runtime: $RUNTIME"
echo ""
echo "[1/3] Building image..."
$RUNTIME build --platform linux/amd64 \
-t "$FULL_TAG" \
-t "localhost/${IMAGE_NAME}:${VERSION}" \
-f "$SCRIPT_DIR/Dockerfile" \
"$REPO_DIR"
echo "[2/3] Pushing to registry..."
if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then
echo ""
echo "Not logged in to $REGISTRY."
echo "Run: $RUNTIME login $REGISTRY"
exit 1
fi
$RUNTIME push "$FULL_TAG"
echo ""
echo "[3/3] Done!"
echo ""
echo "Image pushed: $FULL_TAG"
echo ""
echo "Update apps/podsteadr/manifest.yml's container.image to $FULL_TAG so"
echo "nodes pull it instead of building locally."
+66
View File
@@ -0,0 +1,66 @@
app:
id: portainer
name: Portainer
version: 2.19.4
description: Container management web UI for the local Podman socket.
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 9000
container: 9000
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/portainer
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/portainer/compose
target: /data/compose
options: [rw]
- type: bind
source: /run/user/1000/podman/podman.sock
target: /var/run/docker.sock
options: [rw]
environment: []
interfaces:
main:
name: Web UI
description: Portainer web interface
type: ui
port: 9000
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/portainer.webp
tier: optional
launch:
open_in_new_tab: true
features:
- Container management dashboard
- Local Podman socket access
- Compose stack storage
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
*.log
.git
.gitignore
README.md
+40
View File
@@ -0,0 +1,40 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache \
dbus \
avahi \
avahi-tools
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080 5353 1900
CMD ["node", "dist/index.js"]
+38
View File
@@ -0,0 +1,38 @@
# Archipelago Router
Mesh routing and local network management for Archipelago.
## Building
```bash
# From the apps directory
./build.sh router
# Or manually
cd router
docker build -t archipelago/router:latest .
# or
podman build -t archipelago/router:latest .
```
## Development
```bash
cd router
npm install
npm run dev
```
## Ports
- **8084**: Web UI (dev: 18084)
- **5353**: mDNS/Bonjour (dev: 15353)
- **1900**: SSDP (dev: 11900)
## Running Locally
```bash
docker run -p 8084:8080 -p 5353:5353/udp -p 1900:1900/udp \
-v /tmp/archipelago-dev/router:/app/data \
archipelago/router:latest
```
+75
View File
@@ -0,0 +1,75 @@
app:
id: router
name: Mesh Router
version: 1.0.0
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container:
image: archipelago/router:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 500Mi
resources:
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 500Mi
security:
capabilities: [NET_ADMIN, NET_RAW] # Required for network management
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: host # Requires host network for routing
apparmor_profile: router
ports:
- host: 8084
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
- host: 5353
container: 5353
protocol: udp # mDNS/Bonjour
auth: none
auth_rationale: >-
mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN.
- host: 1900
container: 1900
protocol: udp # SSDP
auth: none
auth_rationale: >-
SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session.
volumes:
- type: bind
source: /var/lib/archipelago/router
target: /app/data
options: [rw]
- type: bind
source: /var/run/dbus
target: /var/run/dbus
options: [ro]
environment:
- NETWORK_INTERFACE=eth0
- MESH_ENABLED=true
- DEVICE_DISCOVERY=true
health_check:
type: http
endpoint: http://localhost:8084
path: /health
interval: 30s
timeout: 5s
retries: 3
networking:
mesh_enabled: true
local_network_access: true
device_discovery: true
routing_protocols: [olsr, babel]
+1486
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "archipelago-router",
"version": "1.0.0",
"description": "Mesh routing and local network management for Archipelago",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"bonjour": "^3.5.0",
"network-interfaces": "^1.1.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}

Some files were not shown because too many files have changed in this diff Show More