exifcleaner-web/docs/deploying.md
forgejo_admin 6e52fd894f
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 26s
CI / E2E (Standalone single-file) (push) Successful in 1m14s
CI / E2E (Web) (push) Successful in 2m15s
docs(direction): standalone HTML + Android APK are the primary targets; drop iOS (#172)
## Summary

Bring all direction-flavoured docs into sync with the May 2026 state of the project.

**Primary distribution targets are now:**
1. **Desktop offline standalone HTML** (`dist/web-standalone/index.html`) — produced by `yarn build:web:standalone`
2. **Android APK** (Capacitor wrapper) — produced by `.github/workflows/build-android.yml` or `scripts/build-apk-local.sh`

**Demoted to secondary:**
- The deployed web PWA (`dist/web/`) is still buildable and self-hostable via the included Docker + Cloudflare Pages paths, but is no longer the recommended user-facing distribution.

**Out of scope:**
- iOS in any form (App Store, PWA via Safari, Add to Home Screen).

## What changed

| File | Change |
|---|---|
| `.claude/rules/project-direction.md` | Main rewrite: "One code path, two distribution targets"; "Mobile = Android APK, not iOS"; "What's NOT in scope" updated; Phase E.1 issue list recast |
| `.claude/rules/modernization-roadmap.md` | Phase E.1 table: #50 (iOS Photos picker) and #52 (PWA install prompt UX) marked out-of-scope; #23 recast from Web Share Target PWA to Android Intent filter; "PWA is sole channel" claim brought current; key constraints updated |
| `.claude/rules/privacy-invariants.md` | §2 expanded to cover all three distribution paths (standalone inlines everything, APK uses Capacitor's localhost interceptor, self-host PWA caches via service worker) |
| `CLAUDE.md` | Top of file + Tech Stack section reflect dual primary distribution |
| `README.md` | Features list + Project Direction section reflect dual primary, iOS dropped, standalone-on-Android note updated to point at the APK |
| `docs/android-apk.md` | Line 21 note flipped (APK is now primary, not "personal-distribution / not official"); comparison table relabelled; AAR conclusion updated |
| `docs/deploying.md` | Reframed as the self-host PWA doc; iOS Safari install instructions removed; intro note clarifies this is secondary distribution |
| `docs/architecture.md` | History note brought current — mentions APK #156 + standalone HTML as primaries |
| `docs/PRIVACY_GAPS.md` | Android filesystem-isolation note updated to recommend the APK path |
| `docs/standalone-html.md` | "No PWA install" trade-off bullet now points at the Android APK |

10 files changed, 81 insertions, 58 deletions.

## Phase E issues to close as out-of-scope

This direction change makes two open issues out-of-scope. Suggested follow-up — close (not in this PR, since closing is a separate decision):

- **#50** iOS Photos picker UX note — iOS dropped entirely
- **#52** PWA install prompt UX — deployed PWA demoted to self-host only

#23 (Web Share Target PWA) is recast rather than closed — the underlying "let users share files into MetaScrub from the Android gallery" feature is still wanted, but the implementation switches from the Web Share Target API in the manifest to a Capacitor `@capacitor/share` / native Intent filter.

## What I deliberately did NOT touch

- **`CHANGELOG.md`** — historical release notes. The Phase G entry says "PWA is the sole distribution channel" which was accurate at the time; changelogs are snapshots, not living documents.
- **`docs/superpowers/plans/2026-05-14-phase-g-rollout.md` + `docs/superpowers/specs/2026-05-14-phase-g-electron-retirement-design.md`** — historical phase plans/specs describing the work as it was at the time of execution.
- **Playwright e2e mobile-iOS configs** — these test responsive layouts under an iOS Safari user agent, useful coverage independent of iOS being a shipping target. Removing them is a separate test-strategy decision, not a direction-doc concern.
- **Source code** — only two iOS references in `src/`, both in comments describing the mobile-browser landscape generally (not iOS-gated code paths). No code change needed for the direction shift.

## Test plan
- [x] All edits are documentation-only; no source code touched
- [x] No `*.md` linting in CI; prettier-check only targets `src/**/*.{ts,tsx}`
- [ ] Reviewer reads `.claude/rules/project-direction.md` first; satellites mirror its language
- [ ] Decide whether to close #50 + #52 as out-of-scope as a follow-up

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Randa <obuvuyoviz26@gmail.com>
Reviewed-on: http://forgejo.localhost:3000/forgejo_admin/exifcleaner-web/pulls/172
2026-05-21 02:27:41 +04:00

11 KiB
Raw Permalink Blame History

Deploying the web PWA (self-host)

Note

MetaScrub's primary distributions are the desktop standalone HTML and the Android APK (see .claude/rules/project-direction.md). This doc covers the secondary, self-host path: deploying the dist/web/ PWA bundle to your own infrastructure for users who'd rather hit a URL than download a file or sideload an APK. The deploy paths below are still supported but are no longer recommended as the main user-facing distribution.

The web build is plain static assets — HTML, JS, CSS, WASM, a manifest, a service worker. Anything that serves static files over HTTPS will host it. Two reference paths are documented below; pick whichever fits.

  • Self-hosted Docker — full control, runs anywhere, no third party serves your code. The bundled nginx.conf already has all required headers configured.
  • Cloudflare Pages — free hosting on a global CDN, auto-deploy from GitHub. Currently the GitHub Actions workflow is set to manual trigger only.

If you have no preference, start with self-hosted Docker + Cloudflare Tunnel — it's the lowest lock-in path and easy to move off of later.

Common requirements

  • HTTPS is mandatory. Browsers refuse to register service workers over plain HTTP, which means PWA install will not work. The only exception is localhost, useful for development.
  • Headers must be set. The Docker image's nginx.conf and the Cloudflare Pages public/_headers are intentionally identical (COOP/COEP for SharedArrayBuffer, CSP with 'wasm-unsafe-eval' for WASM, immutable cache for /assets/*, no-cache for /sw.js). If you change one, change the other.
  • The build output is dist/web/. Produced by yarn build:web. Self-hosted, you'll either copy this into the Docker image (the included Dockerfile does this) or serve it directly from any static host.

Self-hosted Docker

The included Dockerfile is a multi-stage build: stage 1 builds the bundle with Node 22; stage 2 serves it with nginx Alpine. Final image is around 90 MB.

Build and run locally

docker build -t metascrub .
docker run -d -p 8080:80 --name metascrub metascrub
# → reachable at http://localhost:8080

This is enough for local testing. For phone testing or sharing, you need HTTPS — three concrete options below.

Option A: Cloudflare Tunnel (free, no port-forwarding)

Easiest path. Install cloudflared, point it at the local container, get a public HTTPS URL. Works from any network — no router or firewall configuration.

Quick test (random URL, throwaway):

cloudflared tunnel --url http://localhost:8080
# → outputs https://<random>.trycloudflare.com

Stable URL on a domain you own (requires a free Cloudflare account with the domain on Cloudflare's nameservers):

cloudflared tunnel login
cloudflared tunnel create metascrub
cloudflared tunnel route dns metascrub metascrub.example.com
cloudflared tunnel run --url http://localhost:8080 metascrub

Cloudflare provisions and renews TLS certs automatically.

Option B: VPS with a reverse proxy

The "I own the box" version. Any cheap VPS works (Hetzner, DigitalOcean, Vultr — typically $46/month). Run the Docker container on port 8080, put any reverse proxy in front to terminate TLS. Both nginx and Caddy work; pick what you already know.

The Docker container's internal nginx already sets all required response headers (COOP/COEP/CSP). The reverse proxy preserves response headers by default, so no extra header configuration is needed at the proxy layer.

Caddy (shortest config, auto-TLS)

Caddy provisions and renews Let's Encrypt certs automatically. Full config:

metascrub.example.com {
    reverse_proxy localhost:8080
}

caddy run (or systemd unit), point your DNS at the VPS, done.

nginx + certbot (most operators already know it)

# /etc/nginx/sites-enabled/metascrub
server {
    listen 80;
    server_name metascrub.example.com;

    location / {
        proxy_pass http://localhost:8080;
        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;
    }
}

Then provision the cert and rewrite the config to enable HTTPS:

sudo certbot --nginx -d metascrub.example.com

Certbot adds the SSL block and the HTTP→HTTPS redirect. It also installs a renewal cron/timer.

Option C: Tailscale Funnel (home server, no exposed ports)

If the server is at home and you'd rather not expose ports on your router, Tailscale Funnel exposes a Tailscale-internal service to the public internet over HTTPS. Free for personal use.

sudo tailscale funnel --bg --https=443 8080

The service is reachable at https://<machine>.<tailnet>.ts.net.

Cloudflare Pages

Free tier: 500 builds/month, unlimited requests, unlimited bandwidth, 100 custom domains per project. No credit card required.

The GitHub Actions workflow at .github/workflows/deploy-web.yml handles the deploy. It is currently set to manual trigger only (workflow_dispatch) so it doesn't run before secrets are configured. Re-enable automatic deploys by editing the on: block per the comment in the file.

1. Create a Cloudflare account

Sign up at https://dash.cloudflare.com/sign-up.

2. Capture your Account ID

After login, the dashboard URL is https://dash.cloudflare.com/<account-id>. The same value appears in the Workers & Pages tab right sidebar with a copy button.

3. Create an API token scoped to Pages

Use a scoped token, not the global API key.

  1. Top-right profile icon → My ProfileAPI TokensCreate Token
  2. Pick Custom token
  3. Permissions: AccountCloudflare PagesEdit
  4. Account Resources: Include → your account
  5. ContinueCreate Token
  6. Copy the token now — Cloudflare shows it once

4. Add the two secrets to GitHub

In the repo:

  1. SettingsSecrets and variablesActionsNew repository secret
  2. Add CLOUDFLARE_API_TOKEN (token from step 3)
  3. Add CLOUDFLARE_ACCOUNT_ID (account ID from step 2)

5. Trigger the first deploy

While the workflow is on workflow_dispatch:

  • Go to the Actions tab → Deploy Web App to Cloudflare PagesRun workflow → pick branch → Run
  • The first run auto-creates the Pages project with name exifcleaner-web (the legacy CF project name — kept as-is to avoid orphaning an existing deploy; rename via the Cloudflare dashboard if desired)

To re-enable auto-deploys on every push, edit the on: block in the workflow file. Once enabled:

  • Push to master → production deployment
  • Pull request to master → preview deployment per commit (URL posted in the action output)

6. Find your URL

  • Production: https://exifcleaner-web.pages.dev
  • Preview: https://<commit-hash>.exifcleaner-web.pages.dev

Both visible at: Cloudflare dashboard → Workers & Pagesexifcleaner-webDeployments.

7. Custom domain (optional)

Free.

  1. Workers & Pagesexifcleaner-webCustom domainsSet up a custom domain
  2. Enter the domain
  3. Add the DNS records Cloudflare shows. If the domain is on Cloudflare's nameservers, this is automatic.

Installing the self-hosted PWA from a phone

Note

The recommended Android path is the Capacitor APK (docs/android-apk.md) — fully offline from first launch, no HTTPS deploy required. The instructions below are only for users hitting a self-hosted PWA deploy. iOS is out of scope for MetaScrub overall; if it happens to work via Safari Add-to-Home-Screen on a self-host deploy that's a side effect, not a supported target.

Android (Chrome) — self-hosted PWA

  1. Open the URL
  2. Wait a few seconds for the service worker to register
  3. ⋮ menu → Install app (older Chrome: "Add to Home Screen")
  4. Confirm. Icon lands on the home screen, launches in standalone mode (no browser chrome). Subsequent launches work offline.

Offline behaviour

The first visit (online) caches the app shell + WASM modules via the service worker. After that, the PWA loads from cache regardless of connectivity. The actual file processing is in-browser anyway — files never leave the device — so offline use is a first-class case.

Headers configuration

Two files, one truth. Keep them in sync.

File Used by Format
nginx.conf Docker image nginx add_header directives
public/_headers Cloudflare Pages Cloudflare Pages headers syntax

Both apply the same set:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: no-referrer
  • Content-Security-Policy with 'wasm-unsafe-eval'
  • Long-cache for /assets/*, no-cache for /sw.js

Troubleshooting

PWA install option doesn't appear in the Chrome menu — three things to check: (1) the URL is HTTPS, (2) the service worker registered (DevTools → Application → Service Workers should show one as "activated"), (3) the manifest is reachable at /manifest.webmanifest and parses cleanly. Without all three, Chrome won't surface the install prompt.

Service worker doesn't register on Cloudflare Pages — check response headers in the Network tab. COOP/COEP must be on the HTML response. If they're missing, the _headers file likely didn't make it into the build output. Verify dist/web/_headers exists after yarn build:web.

Cloudflare workflow fails with "Authentication error" — the API token scope must be Account → Cloudflare Pages → Edit. Account-level, not zone-level.

Cloudflare workflow fails with "account_id is required"CLOUDFLARE_ACCOUNT_ID is missing or wrong. It's the hex string from the dashboard URL, not the account email.

Phone shows "Add to Home Screen" but the icon launches in a normal browser tab — manifest didn't pass Chrome's PWA criteria. Check that both 192px and 512px icons exist with purpose: "any maskable" and display: "standalone" is set in manifest.webmanifest.

Self-signed cert on a LAN-only setup gives a security warning — browsers will not register service workers behind self-signed certs even if you click through the warning. Use Cloudflare Tunnel or Tailscale Funnel for HTTPS without exposing your home network.