Skip to content

Commit 2033f6c

Browse files
Copexitclaude
andcommitted
fix: nginx proxy for signet/testnet4 networks + Umbrel dev guide
Network-prefix stripping: local mempool already serves the correct chain, so /signet/api/* and /testnet4/api/* rewrite to /api/* before proxying. Without this, non-mainnet analysis requests 404. Also adds docker-compose.test.yml for Tier 1 local testing (no Umbrel needed) and umbrel.md with comprehensive development documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d8cbd6a commit 2033f6c

File tree

4 files changed

+320
-0
lines changed

4 files changed

+320
-0
lines changed

docker-compose.test.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
web:
3+
image: nginxinc/nginx-unprivileged:1.27-alpine
4+
ports:
5+
- "3080:8080"
6+
environment:
7+
APP_MEMPOOL_IP: mempool-proxy
8+
APP_MEMPOOL_PORT: "80"
9+
volumes:
10+
- ./out:/usr/share/nginx/html:ro
11+
- ./umbrel/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
12+
depends_on:
13+
- mempool-proxy
14+
15+
mempool-proxy:
16+
image: nginx:1.27-alpine
17+
volumes:
18+
- ./umbrel/test-mempool-proxy.conf:/etc/nginx/conf.d/default.conf:ro

umbrel.md

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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 |

umbrel/nginx.conf.template

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ server {
2727
proxy_connect_timeout 5s;
2828
}
2929

30+
# Strip network prefix for signet/testnet - local mempool already runs on the right network
31+
location ~ ^/(signet|testnet4)/api/ {
32+
rewrite ^/(signet|testnet4)/api/(.*)$ /api/$2 break;
33+
proxy_pass http://${APP_MEMPOOL_IP}:${APP_MEMPOOL_PORT};
34+
proxy_set_header Host $host;
35+
proxy_set_header X-Real-IP $remote_addr;
36+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
37+
proxy_set_header X-Forwarded-Proto $scheme;
38+
proxy_read_timeout 30s;
39+
proxy_connect_timeout 5s;
40+
}
41+
3042
# SPA fallback: serve index.html for all non-file routes
3143
location / {
3244
try_files $uri $uri/ /index.html;

umbrel/test-mempool-proxy.conf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
server {
2+
listen 80;
3+
resolver 127.0.0.11 valid=30s;
4+
5+
location /api/ {
6+
proxy_pass https://mempool.space/api/;
7+
proxy_ssl_server_name on;
8+
proxy_set_header Host mempool.space;
9+
proxy_set_header Accept-Encoding "";
10+
}
11+
}

0 commit comments

Comments
 (0)