Building a PWA That Feels Like a Native iOS App in 2026 A developer ported a native iOS electrophysiology case-logging app, EPlog, to a SvelteKit progressive web app in 2026 and added a dictation scribe that transcribes procedure audio with ElevenLabs Scribe v2 Medical and extracts structured fields with Claude Haiku. The stack runs one container on a small VPS via Dokploy with Traefik for TLS, SQLite through better-sqlite3 and Drizzle in WAL mode, Litestream streaming writes to Cloudflare R2, and a service worker plus IndexedDB for offline use. The developer reports the PWA now replaces the native app, installing to the home screen, opening full screen, working offline, and updating on git push without App Store review. Building a PWA That Feels Like a Native iOS App in 2026 I have a native iOS app, EPLog https://segar.me/eplog.html , that logs my electrophysiology cases. This year I ported it to the web as a progressive web app, and then added a feature the iOS app never had: a scribe. I've used Doximity Scribe for clinic visits https://segar.me/blog/posts/doximity.html and precharting https://segar.me/blog/posts/doximity precharting.html , but procedure notes follow a fixed structure, and I wanted one built around how I dictate them. I dictate a procedure into my phone, the server transcribes it, Claude pulls out the structured fields, and code assembles the procedure note and discharge summary. On the computer, the same dictation is waiting, ready to copy. I built the PWA as the compromise version, and now I use it instead of the native app. It installs to the home screen, opens full screen, has a floating tab bar, slides between pages like UIKit, works offline, and updates on git push with no App Store review. The scribe makes a good test case because it hits the four things PWAs handle worst on iOS: the microphone, backgrounding, flaky hospital Wi-Fi, and syncing between devices. A note on data: every dictation, transcript, and note in this post is fictional. No PHI appears in the text, screenshots, or code. If you build something like this for real patient data, the compliance questions BAAs with your speech-to-text vendor, LLM provider, object storage, and host, plus your institution's policy come before any of the code below. The Stack One user, one container, one database file: - SvelteKit with adapter-node serves the UI and the API from the same process. - SQLite through better-sqlite3 and Drizzle, in WAL mode. - Litestream streams every write to Cloudflare R2. - Dokploy builds and runs the container on a small VPS, with Traefik in front for TLS. - A service worker caches the app shell. IndexedDB holds anything that has not reached the server yet. - ElevenLabs Scribe v2 Medical for speech-to-text, and Claude Haiku for field extraction. A single-user app has no use for Postgres, Redis, or a job queue. Each service I skip is one less thing to fail at 7 a.m. before a case. Part 1: Deploying to Dokploy One Compose File, Routed by Traefik Labels Dokploy https://dokploy.com is a self-hosted Heroku: point it at a Git repo, and it builds and runs on every push. I covered the server setup in Migrating Static Sites to Dokploy with Traefik and Let's Encrypt https://segar.me/blog/posts/dokploy.html , so this section sticks to what changes for an app with a database. I use a Compose service rather than a plain Dockerfile service, because Compose lets me declare the volume and the routing in the repo instead of clicking them into a dashboard. The file defines one service, a named volume mounted at /data for the database and images, and Traefik labels for the host, the HTTPS entrypoint, and the Let's Encrypt resolver. Things that cost me time: - Pick one place for routing. If you declare Traefik labels, don't also add the domain in Dokploy's Domains tab. You get two routers for the same host and confusing certificate behavior. - ${VAR:?message} stops the deploy with your message when a required secret is missing. I prefer that to an app that boots and then 500s on login. - SvelteKit needs ORIGIN set to the exact URL you open, or its CSRF check rejects every form POST in production while dev works fine. - Behind Cloudflare and Traefik, the client IP is not the last hop. With a proxied DNS record, the real client sits second from the right in X-Forwarded-For . adapter-node reads that through ADDRESS HEADER=X-Forwarded-For and XFF DEPTH=2 . Get it wrong and a login lockout locks out Cloudflare instead of the attacker. - Add a HEALTHCHECK that hits a real endpoint. Dokploy shows container health, and a container that is "running" but can't answer HTTP is the failure you want to see. SQLite in Production For one user, SQLite is the fastest database I have deployed, because no network sits between the app and the data. I set four pragmas when the app opens the file: js const sqlite = new Database path ; sqlite.pragma 'journal mode = WAL' ; // readers don't block the writer; Litestream requires it sqlite.pragma 'synchronous = NORMAL' ; // safe in WAL mode, much faster than FULL sqlite.pragma 'foreign keys = ON' ; // off by default, per connection sqlite.pragma 'busy timeout = 5000' ; // wait for a lock instead of throwing SQLITE BUSY const db = drizzle sqlite, { schema } ; migrate db, { migrationsFolder: migrationsDir } ; Drizzle runs migrations at startup, so a deploy that changes the schema needs no separate step. better-sqlite3 is synchronous, which sounds wrong in Node until you notice that a single-row query takes microseconds and you no longer need await inside transactions. The constraint you accept: one container, one writer. Never scale the service to two replicas. Two processes writing the same SQLite file over a Docker volume is how you corrupt it. Litestream for Backups and Disaster Recovery The volume survives redeploys. It does not survive the VPS dying. Litestream https://litestream.io runs as the container's parent process, ships WAL changes to S3-compatible storage within seconds, and restores the database on an empty volume: bash /bin/sh set -eu if -n "${LITESTREAM REPLICA URL:-}" ; then if -f "$DATABASE PATH" ; then litestream restore -config /app/docker/litestream.yml \ -if-db-not-exists -if-replica-exists "$DATABASE PATH" fi exec litestream replicate -config /app/docker/litestream.yml -exec "node /app/build" fi exec node /app/build With -exec , Litestream starts the app as its child and keeps replicating until the app exits, so the app can't write while replication is down. Moving to a new server is: create the service, set the same environment variables, deploy. The entrypoint finds an empty volume and pulls the database back from R2. The litestream.yml reads the bucket URL, R2 endpoint, and keys from environment variables, so the secrets stay in Dokploy. A daily snapshot kept for 30 days sets how far back a point-in-time restore can go. Scope the R2 token to that one bucket with Object Read & Write only. If someone breaks into the container, the token reaches nothing else. Know Which Build Is Live A git pre-commit hook bumps the patch version in package.json on every commit. The build bakes it in, and GET /api/health returns {"ok":true,"version":"0.0.36","built":"..."} . After a push, I check that number instead of guessing whether Dokploy finished. The service worker names its cache with the same version. Part 2: Looking Native Decide the Shell Before First Paint The app has two looks: an iOS-style UI for the installed app and phones, and a denser web UI for a desktop browser. Choosing in a Svelte component means a flash of the wrong layout. A ten-line blocking script in