3.4 KiB
iOS App Store
Self-hosted iOS OTA distribution server. Node.js/Express + SQLite + Docker, deployed on unraid behind Nginx Proxy Manager.
Live deployment
- URL: https://appstore.treytartt.com
- Container:
ios-appstoreon unraid (port3080internally, proxied via NPM at host10.3.3.11:3080) - App code:
/mnt/user/appdata/ios-appstore/on unraid (Dockerfile, source, compose,.env) - Data volume:
/mnt/user/downloads/ios-appstore/mounted as/data(SQLite DB, IPAs, icons)
This split is intentional — app code in appdata, persistent data in downloads. Don't put data volumes in appdata or app source in downloads.
The .env on the server holds ADMIN_PASSWORD, API_TOKEN, and SESSION_SECRET. Read it from /mnt/user/appdata/ios-appstore/.env when you need them.
Deploy flow
# 1. Sync local changes to unraid (excludes node_modules, data, .env)
rsync -avz --exclude node_modules --exclude data --exclude .env \
/Users/m4mini/Desktop/code/ios-appstore/ \
unraid:/mnt/user/appdata/ios-appstore/
# 2. Rebuild and restart
ssh unraid "cd /mnt/user/appdata/ios-appstore && docker compose up -d --build"
# 3. Verify
ssh unraid "docker logs ios-appstore --tail 20 && curl -s http://localhost:3080/api/health"
docker-compose.yml builds the image from local source — no registry. The data volume persists across rebuilds.
Architecture
src/server.js— Express app, all routes, multer upload handlingsrc/db.js— SQLite schema (apps, builds, devices)src/ipa-parser.js— Unzips IPA, extractsInfo.plistand app iconsrc/manifest.js— Generates the OTA manifest plist iOS fetchessrc/auth.js— Session middleware (web UI) + token middleware (API)views/— Login, app listing, upload pages (vanilla HTML)public/— CSS + client-side JS
Apps are keyed by bundle ID. Uploading the same bundle ID adds a new build to the existing app instead of duplicating it.
Auth model
Two parallel auth schemes:
- Session cookies for browser users (login at
/loginwithADMIN_PASSWORD) X-Api-Tokenheader for CLI/automation (API_TOKEN)
requireAuth accepts either. The manifest and IPA download endpoints (/api/manifest/:id, /api/download/:id) are intentionally public — iOS fetches them unauthenticated during the OTA install.
OTA gotchas
- Development-signed IPAs cannot be installed OTA. iOS only allows OTA installs of
ad-hocorenterprise-signed builds. If a user reports "integrity could not be verified", first check the export method in theirExportOptions.plist. - Ad-hoc requires a distribution certificate (not a development cert) and an ad-hoc provisioning profile with the target device UDIDs registered.
- HTTPS is mandatory and must use a trusted CA. Self-signed certs don't work on iOS 12+. NPM's Let's Encrypt cert handles this.
- The manifest's
bundle-identifiermust exactly match the IPA's bundle ID.
Testing changes
For backend changes, after deploying:
# Health
curl -s https://appstore.treytartt.com/api/health
# List apps (needs token)
curl -s -H "X-Api-Token: $TOKEN" https://appstore.treytartt.com/api/apps
# Upload an IPA
curl -X POST https://appstore.treytartt.com/api/upload \
-H "X-Api-Token: $TOKEN" \
-F "ipa=@/path/to/App.ipa" \
-F "notes=test"
Get $TOKEN from /mnt/user/appdata/ios-appstore/.env on unraid.
For frontend changes, just rsync + restart and refresh the browser.