|
| 1 | +# Umbrel App Development Guide |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +am-i.exposed runs as a self-hosted Umbrel app that routes all API calls through the user's local mempool.space instance. No data leaves the local network. |
| 6 | + |
| 7 | +**Architecture:** |
| 8 | +``` |
| 9 | +Browser -> Umbrel app_proxy (auth) -> nginx (port 8080) -> static SPA |
| 10 | + | |
| 11 | + +-> /api/* -> local mempool:3006/api/* |
| 12 | + +-> /signet/api/* -> local mempool:3006/api/* |
| 13 | + +-> /testnet4/api/* -> local mempool:3006/api/* |
| 14 | +``` |
| 15 | + |
| 16 | +## Repositories |
| 17 | + |
| 18 | +| Repo | Purpose | |
| 19 | +|------|---------| |
| 20 | +| `github.com/Copexit/am-i-exposed` | Main app source + Dockerfile.umbrel + nginx config | |
| 21 | +| `github.com/Copexit/copexit-umbrel-app-store` | Umbrel community app store (manifest, docker-compose, icon) | |
| 22 | + |
| 23 | +## Docker Image |
| 24 | + |
| 25 | +- **Registry:** `ghcr.io/copexit/am-i-exposed-umbrel` |
| 26 | +- **IMPORTANT:** The GHCR package must be set to **public** visibility from GitHub Package Settings, otherwise Umbrel instances will get 401 when pulling |
| 27 | +- **Base image:** `nginxinc/nginx-unprivileged:1.27-alpine` (runs as non-root UID 101) |
| 28 | +- **Do NOT set `user: "1000:1000"`** in docker-compose - it breaks nginx-unprivileged's file permissions and prevents envsubst from rendering the config template |
| 29 | + |
| 30 | +### Building locally (without full Dockerfile.umbrel) |
| 31 | + |
| 32 | +The multi-stage `Dockerfile.umbrel` requires network access for `pnpm install`. When building in sandboxed environments, use a two-step approach: |
| 33 | + |
| 34 | +```bash |
| 35 | +# 1. Build static export on host |
| 36 | +pnpm build |
| 37 | + |
| 38 | +# 2. Temporarily remove "out" from .dockerignore, then: |
| 39 | +cat > /tmp/Dockerfile.local << 'EOF' |
| 40 | +FROM nginxinc/nginx-unprivileged:1.27-alpine |
| 41 | +USER root |
| 42 | +RUN rm -f /etc/nginx/conf.d/default.conf |
| 43 | +USER 1000 |
| 44 | +COPY umbrel/nginx.conf.template /etc/nginx/templates/default.conf.template |
| 45 | +COPY out/ /usr/share/nginx/html |
| 46 | +EXPOSE 8080 |
| 47 | +EOF |
| 48 | + |
| 49 | +docker build -f /tmp/Dockerfile.local -t ghcr.io/copexit/am-i-exposed-umbrel:vX.Y.Z . |
| 50 | + |
| 51 | +# 3. Restore .dockerignore |
| 52 | +``` |
| 53 | + |
| 54 | +### CI/CD |
| 55 | + |
| 56 | +The GitHub Actions workflow `.github/workflows/docker-umbrel.yml` builds and pushes automatically on `v*` tags. It builds for both `linux/amd64` and `linux/arm64` (Raspberry Pi). |
| 57 | + |
| 58 | +### Pushing manually |
| 59 | + |
| 60 | +```bash |
| 61 | +echo "$GHCR_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin |
| 62 | +docker push ghcr.io/copexit/am-i-exposed-umbrel:vX.Y.Z |
| 63 | +``` |
| 64 | + |
| 65 | +## Community App Store Structure |
| 66 | + |
| 67 | +``` |
| 68 | +copexit-umbrel-app-store/ |
| 69 | + umbrel-app-store.yml # id: copexit, name: Copexit |
| 70 | + copexit-am-i-exposed/ |
| 71 | + umbrel-app.yml # App manifest (name, version, deps, icon URL) |
| 72 | + docker-compose.yml # app_proxy + web service |
| 73 | + exports.sh # Static IP: 10.21.22.50 |
| 74 | + icon.svg # 256x256 eye logo (square corners, no rounded rect) |
| 75 | +``` |
| 76 | + |
| 77 | +### Key manifest fields (`umbrel-app.yml`) |
| 78 | + |
| 79 | +```yaml |
| 80 | +manifestVersion: 1 |
| 81 | +id: copexit-am-i-exposed |
| 82 | +category: bitcoin |
| 83 | +name: "am-i.exposed" |
| 84 | +version: "0.1.1" |
| 85 | +icon: https://raw.githubusercontent.com/Copexit/copexit-umbrel-app-store/master/copexit-am-i-exposed/icon.svg |
| 86 | +dependencies: |
| 87 | + - mempool |
| 88 | +port: 3080 |
| 89 | +``` |
| 90 | +
|
| 91 | +### Icon gotcha for community stores |
| 92 | +
|
| 93 | +Umbrel's icon fallback URL points to `getumbrel.github.io/umbrel-apps-gallery/{app-id}/icon.svg` which only works for official apps. Community apps **must** set an explicit `icon:` field in `umbrel-app.yml` with a full public URL. Use raw.githubusercontent.com for the repo's icon.svg. |
| 94 | + |
| 95 | +The same applies to `gallery:` images - they must be full URLs, not filenames. |
| 96 | + |
| 97 | +### docker-compose.yml |
| 98 | + |
| 99 | +```yaml |
| 100 | +version: "3.7" |
| 101 | +services: |
| 102 | + app_proxy: |
| 103 | + environment: |
| 104 | + APP_HOST: copexit-am-i-exposed_web_1 |
| 105 | + APP_PORT: 8080 |
| 106 | + PROXY_AUTH_ADD: "false" |
| 107 | + web: |
| 108 | + image: ghcr.io/copexit/am-i-exposed-umbrel:v0.1.1 |
| 109 | + init: true |
| 110 | + restart: on-failure |
| 111 | + environment: |
| 112 | + APP_MEMPOOL_IP: ${APP_MEMPOOL_IP} # Injected by Umbrel from mempool's exports.sh |
| 113 | + APP_MEMPOOL_PORT: ${APP_MEMPOOL_PORT} |
| 114 | + networks: |
| 115 | + default: |
| 116 | + ipv4_address: ${APP_COPEXIT_AM_I_EXPOSED_IP} # 10.21.22.50 from our exports.sh |
| 117 | +``` |
| 118 | + |
| 119 | +**Do NOT add `user: "1000:1000"`** - the nginx-unprivileged image runs as UID 101 (nginx user) and its config directories are owned by that user. Overriding to 1000 breaks envsubst template rendering. |
| 120 | + |
| 121 | +## Nginx Config Template |
| 122 | + |
| 123 | +Located at `umbrel/nginx.conf.template`. Uses `nginxinc/nginx-unprivileged` auto-envsubst: files in `/etc/nginx/templates/*.template` are processed at startup, substituting `${VAR}` from environment. |
| 124 | + |
| 125 | +Key proxy rules: |
| 126 | +- `/api/*` -> `http://${APP_MEMPOOL_IP}:${APP_MEMPOOL_PORT}/api/*` (direct proxy) |
| 127 | +- `/(signet|testnet4)/api/*` -> rewritten to `/api/*` then proxied (network-prefix stripping) |
| 128 | + |
| 129 | +The network-prefix stripping is necessary because the app constructs URLs like `/signet/api/tx/{txid}` for non-mainnet networks, but the local mempool already runs on the correct network, so `/api/tx/{txid}` suffices. |
| 130 | + |
| 131 | +## How Local API Detection Works |
| 132 | + |
| 133 | +`src/hooks/useLocalApi.ts` probes `/api/blocks/tip/height` on the same origin at page load: |
| 134 | +- On Umbrel: nginx proxies this to the local mempool, returns a block height integer -> "available" |
| 135 | +- On GitHub Pages: no `/api/` route, returns 404 -> "unavailable" |
| 136 | +- Result is cached per page load (module-level singleton) |
| 137 | + |
| 138 | +When "available", the app's `NetworkContext` sets `mempoolBaseUrl` to `/api` (relative) instead of `https://mempool.space/api`. The `ConnectionBadge` component shows a green shield with "Local". |
| 139 | + |
| 140 | +### ResultsPanel relative URL fix |
| 141 | + |
| 142 | +`ResultsPanel.tsx` constructs a "View on mempool.space" link. When `mempoolBaseUrl` is `/api` (relative), `new URL("/api")` throws. The fix checks `startsWith("/")` first and displays "local API" as hostname. |
| 143 | + |
| 144 | +## Local Testing |
| 145 | + |
| 146 | +### Tier 1: Docker Compose mock (quick, no Umbrel needed) |
| 147 | + |
| 148 | +```bash |
| 149 | +docker compose -f docker-compose.test.yml up --build |
| 150 | +# App at http://localhost:3080 |
| 151 | +# ConnectionBadge shows "Local" |
| 152 | +# curl http://localhost:3080/api/blocks/tip/height -> mainnet height |
| 153 | +``` |
| 154 | + |
| 155 | +### Tier 2: Full Umbrel dev environment |
| 156 | + |
| 157 | +#### Prerequisites |
| 158 | +- Linux with Docker (native, container IPs exposed) |
| 159 | +- macOS: OrbStack required (Docker Desktop won't work - container IPs not exposed) |
| 160 | +- ~15 GB disk |
| 161 | + |
| 162 | +#### Setup |
| 163 | + |
| 164 | +```bash |
| 165 | +# Clone Umbrel monorepo |
| 166 | +git clone https://github.com/getumbrel/umbrel.git |
| 167 | +cd umbrel |
| 168 | +npm run dev start |
| 169 | +# First run builds the OS image (~10 min) |
| 170 | +# Access at http://umbrel-dev.local or container IP (docker inspect umbrel-dev) |
| 171 | +``` |
| 172 | + |
| 173 | +#### DNS issues in sandboxed environments |
| 174 | + |
| 175 | +The Umbrel dev environment builds a Debian image that needs DNS. If Docker DNS is broken: |
| 176 | + |
| 177 | +1. Find a working DNS: `resolvectl status` -> look for "DNS Servers" on a working interface |
| 178 | +2. For build phase: create a buildx builder with `--driver-opt network=host` |
| 179 | +3. For runtime: add `--dns <IP>` to the docker run command in `scripts/umbrel-dev` |
| 180 | +4. The overlay mount may fail in Docker-in-Docker - the dev script handles this gracefully with a fallback |
| 181 | + |
| 182 | +#### Install stack |
| 183 | + |
| 184 | +```bash |
| 185 | +# Register user (via tRPC API - raw JSON, no {"json":...} wrapper) |
| 186 | +curl http://<IP>/trpc/user.register -X POST -H 'Content-Type: application/json' \ |
| 187 | + -d '{"name":"dev","password":"devdevdev"}' |
| 188 | +
|
| 189 | +# Login |
| 190 | +curl http://<IP>/trpc/user.login -X POST -H 'Content-Type: application/json' \ |
| 191 | + -d '{"password":"devdevdev"}' |
| 192 | +# -> returns {"result":{"data":"<JWT>"}} |
| 193 | +
|
| 194 | +# Install Bitcoin Core |
| 195 | +curl http://<IP>/trpc/apps.install -X POST \ |
| 196 | + -H 'Authorization: Bearer <JWT>' -H 'Content-Type: application/json' \ |
| 197 | + -d '{"appId":"bitcoin"}' |
| 198 | +
|
| 199 | +# Switch to signet (edit settings, restart app) |
| 200 | +# File: /home/umbrel/umbrel/app-data/bitcoin/data/app/settings.json |
| 201 | +# Change "chain": "main" to "chain": "signet" |
| 202 | +# Signet syncs in ~30 min vs days for mainnet |
| 203 | +
|
| 204 | +# Install Electrs + Mempool |
| 205 | +curl ... -d '{"appId":"electrs"}' |
| 206 | +curl ... -d '{"appId":"mempool"}' |
| 207 | +
|
| 208 | +# Add community app store |
| 209 | +curl http://<IP>/trpc/appStore.addRepository -X POST \ |
| 210 | + -H 'Authorization: Bearer <JWT>' -H 'Content-Type: application/json' \ |
| 211 | + -d '{"url":"https://github.com/Copexit/copexit-umbrel-app-store.git"}' |
| 212 | +
|
| 213 | +# Install our app |
| 214 | +curl ... -d '{"appId":"copexit-am-i-exposed"}' |
| 215 | +``` |
| 216 | + |
| 217 | +#### tRPC API notes |
| 218 | + |
| 219 | +- Mutations use **POST** with raw JSON body (NOT `{"json": {...}}` - that's the superjson format which Umbrel doesn't use) |
| 220 | +- Queries use **GET** |
| 221 | +- Auth header: `Authorization: Bearer <JWT>` |
| 222 | +- The CLI client (`npm run client`) uses WebSocket for most calls and may hang - prefer curl for automation |
| 223 | + |
| 224 | +#### Loading private Docker images |
| 225 | + |
| 226 | +If the GHCR package isn't public yet, load images manually: |
| 227 | + |
| 228 | +```bash |
| 229 | +# Save from host Docker, pipe into container Docker |
| 230 | +docker save ghcr.io/copexit/am-i-exposed-umbrel:vX.Y.Z | \ |
| 231 | + docker exec -i umbrel-dev docker load |
| 232 | +
|
| 233 | +# WARNING: Umbrel's uninstall nukes app images. Reload after each uninstall. |
| 234 | +``` |
| 235 | + |
| 236 | +Umbrel's install process calls `docker.pull()` via Dockerode before running `docker compose up`. If the image exists locally but the registry returns 401/404, the install fails. To work around this during development, patch `packages/umbreld/source/modules/utilities/docker-pull.ts` to check local images first: |
| 237 | + |
| 238 | +```typescript |
| 239 | +// At the top of the pull() function, add: |
| 240 | +try { |
| 241 | + await docker.getImage(image).inspect() |
| 242 | + handleAlreadyDownloaded() |
| 243 | + updateProgress(1) |
| 244 | + return true |
| 245 | +} catch {} |
| 246 | +// Then proceed with normal pull |
| 247 | +``` |
| 248 | + |
| 249 | +Also patch `packages/umbreld/source/modules/apps/legacy-compat/app-script` to make pulls non-fatal: |
| 250 | +```bash |
| 251 | +# Change: compose "${app}" pull |
| 252 | +# To: compose "${app}" pull --ignore-pull-failures || true |
| 253 | +``` |
| 254 | + |
| 255 | +## Release Checklist |
| 256 | + |
| 257 | +1. `pnpm lint` (0 errors) |
| 258 | +2. `pnpm build` (static export to `out/`) |
| 259 | +3. Tag: `git tag vX.Y.Z && git push --tags` (triggers CI Docker build) |
| 260 | +4. Or build/push manually: `docker build -f Dockerfile.umbrel -t ghcr.io/copexit/am-i-exposed-umbrel:vX.Y.Z . && docker push ...` |
| 261 | +5. **Make GHCR package public** (GitHub > Packages > am-i-exposed-umbrel > Settings > Visibility > Public) |
| 262 | +6. Update `copexit-umbrel-app-store`: |
| 263 | + - `docker-compose.yml`: bump image tag |
| 264 | + - `umbrel-app.yml`: bump `version` field |
| 265 | + - `git push origin master` |
| 266 | +7. Users update the app store in Umbrel UI to pick up the new version |
| 267 | + |
| 268 | +## Verified Test Results |
| 269 | + |
| 270 | +| Test | Network | Result | |
| 271 | +|------|---------|--------| |
| 272 | +| Health check `/health` | - | `ok` | |
| 273 | +| API proxy `/api/blocks/tip/height` | signet | Block height (e.g. 292382) | |
| 274 | +| Network-prefix `/signet/api/tx/{txid}` | signet | JSON tx data | |
| 275 | +| ConnectionBadge | - | Green "Local" shield | |
| 276 | +| Whirlpool CoinJoin (Tier 1, mainnet) | mainnet | A+ 100/100 | |
| 277 | +| Satoshi address (Tier 1, mainnet) | mainnet | F 0/100 | |
| 278 | +| Signet tx analysis (Tier 2) | signet | C 53/100 | |
| 279 | +| App install/uninstall | - | Clean both ways | |
0 commit comments