# WebToApp — Deployment Guide
**English** · [简体中文](DEPLOY.zh.md) · [日本語](DEPLOY.ja.md) · [العربية](DEPLOY.ar.md) · [Русский](DEPLOY.ru.md) · [Español](DEPLOY.es.md) · [Português](DEPLOY.pt.md) · [Français](DEPLOY.fr.md) · [Deutsch](DEPLOY.de.md)
A step-by-step guide to running WebToApp in production.
---
## Contents
1. [Requirements](#1-requirements)
2. [Get the code](#2-get-the-code)
3. [Python environment](#3-python-environment)
4. [Configuration](#4-configuration)
5. [Run locally](#5-run-locally)
6. [Run as a service (systemd)](#6-run-as-a-service-systemd)
7. [Reverse proxy (Nginx)](#7-reverse-proxy-nginx)
8. [HTTPS](#8-https)
9. [Android APK builds (optional)](#9-android-apk-builds-optional)
10. [iOS profile signing (optional)](#10-ios-profile-signing-optional)
11. [Cloudflare R2 offload (optional)](#11-cloudflare-r2-offload-optional)
12. [Updating](#12-updating)
13. [Troubleshooting](#13-troubleshooting)
---
## 1. Requirements
- **Python 3.10+**
- A Linux server (any distro). 1 vCPU / 1 GB RAM is enough to start.
- Outbound internet access (the analyzer fetches target sites).
- Optional, only for real Android APK builds: **Android SDK** (`aapt2`, `d8`, `apksigner`, `zipalign`), **apktool**, a **JDK** (`java` / `javac` / `keytool`). Without them, Android falls back to an installable PWA package.
- Optional, only for iOS profile signing: **openssl** (present on virtually every Linux box).
The only hard dependency is Python. Everything else is optional and degrades gracefully.
## 2. Get the code
```bash
git clone https://github.com/shiahonb777/WebToApp.git
cd WebToApp
```
## 3. Python environment
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r server/requirements.txt
```
This installs the four runtime dependencies: `fastapi`, `uvicorn[standard]`, `httpx`, `Pillow`. Cloudflare R2 offload (optional) needs no extra package — see §11.
## 4. Configuration
All configuration is read from environment variables — every one is optional with a sensible default.
```bash
cp .env.example .env
# edit .env
```
| Variable | Purpose | Default |
| --- | --- | --- |
| `PUBLIC_BASE_URL` | Public origin, e.g. `https://app.example.com`. Required in production so iPhones don't try to open `localhost`. | inferred from Host header |
| `ANDROID_PACKAGE_PREFIX` | Default Android package prefix. | `com.webtoapp` |
| `ANDROID_KEYSTORE_DIR` | Where per-app signing keystores live. Keep it OUTSIDE any public path. | `certs/app-keys` |
| `DAILY_BUILD_QUOTA` | Per-device daily build limit (`0` disables). | `10` |
| `IOS_CERT_FILE` / `IOS_KEY_FILE` / `IOS_CHAIN_FILE` | Public-CA cert to sign iOS profiles. | unset (unsigned, still installable) |
| `R2_ACCOUNT_ID` / `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` / `R2_BUCKET` / `R2_PUBLIC_BASE_URL` | Cloudflare R2 offload (see §11). | unset (downloads served locally) |
| `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID` | Eagerly purge the iOS `/launch` redirect from cache on URL swap. | unset |
> **Never commit your real `.env`.** It is git-ignored by default.
## 5. Run locally
```bash
uvicorn server.main:app --host 127.0.0.1 --port 8000
```
Open . For local development you don't need any environment variables.
## 6. Run as a service (systemd)
Run the service as a dedicated, unprivileged user — never as root. Any RCE in
the app then stops at an account that can only touch `generated/`, `certs/`
and the APK template/tools caches.
```bash
sudo useradd -r -s /sbin/nologin -d /var/lib/webtoapp -M webtoapp
sudo mkdir -p /var/lib/webtoapp/.local/share /var/lib/webtoapp/.cache
sudo chown -R webtoapp:webtoapp /var/lib/webtoapp
# Writable paths the service needs (adjust to your deploy root):
sudo chown -R webtoapp:webtoapp \
/path/to/WebToApp/generated \
/path/to/WebToApp/certs \
/path/to/WebToApp/server/engine/_android_template \
/path/to/WebToApp/server/engine/_android_tools
```
Keep secrets in a locked-down environment file instead of inline in the unit:
```bash
# /path/to/WebToApp/webtoapp.env (chmod 600)
PUBLIC_BASE_URL=https://your-domain.com
# Shared secret for GET /api/metrics (unset = loopback-only access)
METRICS_TOKEN=change-me
# add R2_* / IOS_* / CLOUDFLARE_* here as needed
```
```ini
# /etc/systemd/system/webtoapp.service
[Unit]
Description=WebToApp
After=network.target
[Service]
User=webtoapp
Group=webtoapp
WorkingDirectory=/path/to/WebToApp
EnvironmentFile=/path/to/WebToApp/webtoapp.env
Environment=HOME=/var/lib/webtoapp
Environment=XDG_DATA_HOME=/var/lib/webtoapp/.local/share
Environment=XDG_CACHE_HOME=/var/lib/webtoapp/.cache
ExecStart=/path/to/WebToApp/venv/bin/uvicorn server.main:app --host 127.0.0.1 --port 8000 --workers 1
Restart=always
RestartSec=5
# ---- hardening ----
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/path/to/WebToApp
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target
```
> **HOME must point somewhere writable** (`/var/lib/webtoapp` above), and the
> project root itself should stay owned by root. apktool needs to create
> `~/.local/share/apktool` even when only *decoding*; when it cannot, it
> fails **silently** — decoded manifests come back with empty attribute
> values and every APK build falls back to the PWA ZIP. `ProtectHome=yes` is
> unaffected: it fences off `/home`, `/root` and `/run/user`, not
> `/var/lib/webtoapp`.
>
> Keep `--workers 1`. The build queue and in-memory rate limiter assume a
> single process.
```bash
sudo chmod 600 /path/to/WebToApp/webtoapp.env
sudo systemctl daemon-reload
sudo systemctl enable --now webtoapp
sudo systemctl status webtoapp
# Smoke-test a real build afterwards; "android.apk" must be present:
# curl -s http://127.0.0.1:8000/healthz
```
## 7. Reverse proxy (Nginx)
The app serves its own static frontend, so Nginx only needs to proxy everything to the Uvicorn port:
```nginx
server {
listen 80;
server_name your-domain.com;
client_max_body_size 25m; # custom icon uploads
location / {
proxy_pass http://127.0.0.1:8000;
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_read_timeout 120s; # APK builds can take a while
}
}
```
```bash
sudo nginx -t && sudo systemctl reload nginx
```
## 8. HTTPS
iOS Web Clips and `.mobileconfig` profiles require HTTPS. Two common options:
**Option A — Cloudflare Tunnel** (no open inbound ports, free TLS):
```bash
cloudflared tunnel login
cloudflared tunnel create webtoapp
# route a hostname to the tunnel, then point it at http://127.0.0.1:8000
cloudflared tunnel route dns webtoapp your-domain.com
cloudflared tunnel run webtoapp
```
**Option B — Let's Encrypt on Nginx:**
```bash
sudo certbot --nginx -d your-domain.com
```
Either way, set `PUBLIC_BASE_URL=https://your-domain.com`.
## 9. Android APK builds (optional)
To produce a real, installable WebView APK the server needs the Android build tools:
- Android SDK with `aapt2`, `d8`, `apksigner`, `zipalign`
- `apktool`
- a JDK providing `java`, `javac`, `keytool`
### One-shot install (Linux)
```bash
# root (or writable /opt and /usr/local/bin) required
sudo bash server/scripts/install_android_sdk.sh
```
The script installs to `/opt/android-sdk` (platform 36 + build-tools 36.0.0) and places
`apktool` at `server/engine/_android_tools/apktool.jar` plus `/usr/local/bin/apktool`.
Then set in your env file (or systemd `EnvironmentFile`):
```bash
ANDROID_HOME=/opt/android-sdk
ANDROID_SDK_ROOT=/opt/android-sdk
PATH=/opt/android-sdk/build-tools/36.0.0:/opt/android-sdk/platform-tools:/usr/local/bin:$PATH
```
Restart the service and check `/api/metrics` for `"android_apk": true`.
### Rebuild historical zip fallbacks into real APKs
If the server previously lacked the SDK, some apps only have `android.zip`. After the toolchain is ready:
```bash
# rebuild apps missing android.apk and upload to R2 when configured
python -m server.scripts.rebuild_android_apks
# first 20 only / specific app
python -m server.scripts.rebuild_android_apks --limit 20
python -m server.scripts.rebuild_android_apks --app-id abcd1234
```
Each generated app gets its **own** signing certificate (stored under `ANDROID_KEYSTORE_DIR`), so updates install in place.
**Without the SDK**, APK generation is skipped and Android users get an installable PWA package instead — everything else still works.
## 10. iOS profile signing (optional)
By default the iOS `.mobileconfig` is unsigned (iOS still installs it, just shows "Unverified"). To have iOS show your domain as the source, provide a public-CA certificate via `IOS_CERT_FILE` / `IOS_KEY_FILE` / `IOS_CHAIN_FILE`, or drop `certs/ios-cert.pem`, `certs/ios-key.pem`, `certs/ios-chain.pem`. Signing uses the system `openssl`. See [`certs/README.md`](../certs/README.md).
## 11. Cloudflare R2 offload (optional)
### How it works
Generated installers (APK / ZIP / `.mobileconfig`) can be heavy, and serving every download from the origin burns its bandwidth. With R2 enabled:
1. **After each build**, every file in `generated//downloads/` is uploaded to R2 under the key `/downloads/` (see `server/engine/storage.py`). The resulting public URLs are saved into the app's `recipe.json` as a `downloads_cdn` map.
2. **On download**, `GET /a//download/` checks `downloads_cdn`. If a CDN URL exists it returns a **302 redirect** to R2; otherwise it falls back to streaming the local file. So the origin only spends CPU during builds, not bandwidth on every share/QR scan.
3. **On cleanup**, when an app is reclaimed its objects under `/` are deleted from R2 too.
If any R2 variable is unset, the whole feature becomes a no-op and downloads are served locally — nothing breaks.
> **Implementation note:** R2 speaks the S3 API, which authenticates with AWS Signature V4. Rather than pull in the heavy `boto3`/`botocore` stack, `server/engine/storage.py` ships its own SigV4 signer (standard-library `hmac`/`hashlib`) and sends requests over `httpx` — the same HTTP client the app already uses. So R2 offload needs **no AWS SDK**; the signer is validated against AWS's published SigV4 test vectors (`python -m server.engine.storage`).
### Setup
1. In the Cloudflare dashboard, open **R2** and create a bucket, e.g. `webtoapp-downloads`.
2. **Manage R2 API Tokens → Create API Token** with **Object Read & Write**. Copy the **Access Key ID** and **Secret Access Key** (the secret is shown only once).
3. Make the bucket public: bucket **Settings → Public access**. Either enable the **r2.dev** development URL (`https://pub-xxxx.r2.dev`) for a quick start, or add a **Custom Domain** (e.g. `files.example.com`) to also get edge caching.
> A custom domain must be on a domain managed by **the same Cloudflare account** as the bucket.
4. Set the five variables in `webtoapp.env`:
```bash
R2_ACCOUNT_ID=... # your account ID (hex)
R2_BUCKET=webtoapp-downloads
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_PUBLIC_BASE_URL=https://pub-xxxx.r2.dev # or https://files.example.com
```
5. Restart the service. New builds now redirect downloads to R2.
> **r2.dev vs custom domain:** `pub-xxxx.r2.dev` already serves from Cloudflare's global edge. A custom domain adds edge **caching** (repeat downloads of the same file are served from cache without hitting R2), which matters more at higher traffic.
### Backfill existing apps
Apps built before R2 was enabled still point at local files. Upload their artifacts to R2 and update their `downloads_cdn` in one pass:
```bash
set -a; . ./webtoapp.env; set +a
venv/bin/python -m server.scripts.backfill_r2 --dry-run # preview
venv/bin/python -m server.scripts.backfill_r2 # run for real
```
The script is idempotent — safe to re-run.
## 12. Updating
```bash
git pull
source venv/bin/activate
pip install -r server/requirements.txt # if dependencies changed
sudo systemctl restart webtoapp
```
If you changed frontend assets (`css/`, `js/`), bump the `?v=` query string in `index.html` so browsers fetch the new files instead of cached ones.
## 13. Troubleshooting
| Symptom | Likely cause / fix |
| --- | --- |
| iPhone opens the page in Safari instead of fullscreen | `PUBLIC_BASE_URL` not set, or not HTTPS. |
| Android download is a PWA zip, not an APK | Android SDK / apktool not installed on the server (see §9). |
| Downloads still served from origin | An `R2_*` variable is missing, or you didn't restart after setting them. Run the backfill for old apps (§11). |
| iOS profile shows "Unverified" | Profile is unsigned. Provide a public-CA cert (§10). |
| `502 Bad Gateway` | The service isn't running or the port is wrong — `systemctl status webtoapp`. |
| Build endpoint returns `429` | Per-device daily quota or per-IP rate limit hit. Tune `DAILY_BUILD_QUOTA`. |
---
See also the [README](../README.md) for an overview of the project.