I had an old iPhone in a drawer. It is stuck on iOS 12.5.8 and cannot install current apps. The camera still works.

I wanted to check on my pet while I am away. I tried the App Store first. I found no app that runs on iOS 12 and is free to use. Every one I tried had in-app purchases or severe limits, such as a single camera.

So I wrote a web page instead. Safari opens it, and it publishes the camera over WebRTC to Home Assistant. There is nothing to install on the phone, no subscription and no limits. It also sends a push with a picture when something moves.

Three things get in the way. iOS only gives camera access to HTTPS pages. Home Assistant's own HTTPS breaks plain-HTTP clients. And iOS 12 Safari would not open a websocket to my server at all.

Contents

  • Overview
  • Setup
  • Operate
  • Troubleshooting
  • What I would change

Overview

old iPhone (Safari, iOS 12) Home Assistant OS https://haos.home.arpa/local/cam.html | +-----------------------------+ | 1. HTTPS POST (SDP offer) | NGINX SSL proxy app :443 | +-----------------------------------> | /go2rtc/ -> go2rtc :1984 | | | everything else -> HA :80 | | 2. WebRTC video, UDP :8555 | | +-----------------------------------> | go2rtc (WebRTC Camera) | | | motion on the phone | package pet_cam.yaml | | 3. POST /api/webhook/<id> | webhook -> snapshot -> | +-----------------------------------> | notify mobile app | +-----------------------------+ 
  • No app on the phone. Safari opens one web page.
  • The phone publishes its camera to go2rtc over WebRTC.
  • Signalling is one HTTPS POST, not a websocket.
  • Movement is detected on the phone. The server does no video processing.
  • Home Assistant sends a push with a snapshot to the Companion app on another phone.
  • It survives an HA restart and a full VM reboot. I tested both.
  • Watching while away needs a way into your home network. I use Tailscale with a subnet router and an app connector for *.home.arpa, described in an earlier article. The viewing device must trust the CA from Step 5. Remote viewing is not part of the steps below and I did not test it for this article.

Before you start

What I ran this on:

  • Home Assistant OS 2026.10 in a Proxmox VM, 2 cores, 4 GB RAM.
  • HACS installed.
  • A LAN DNS server I can add records to (Pi-hole). The router's DNS would also do.
  • A second phone with the Home Assistant Companion app, for the push.
  • A Linux or macOS machine with openssl.
  • The Terminal & SSH app installed in HA. Every file write below uses it.
  • Phone and HA on the same LAN. The phone must reach HA on TCP 443 and UDP 8555 (WebRTC media). The checks that use http://haos.home.arpa:1984 also need TCP 1984 from the laptop. If your network blocks it, use https://haos.home.arpa/go2rtc/api/streams with --cacert ca.crt after Step 6.

Each step says where to run things:

  • laptop: your own machine.
  • HA UI: the Home Assistant web interface.
  • HA shell: the Terminal & SSH app. It has the ha command and mounts /config, /ssl and /share. Write files with cat > /path and paste, or with nano.
  • Pi-hole host: wherever your DNS server runs.

Names used below: haos.home.arpa for the hostname, 192.168.1.50 for the HA address. Replace both with yours. home.arpa is the name reserved for home networks (RFC 8375).

Why a proxy and not HA's own TLS

HA can serve TLS itself with ssl_certificate in the http: config. I did not use it. Two reasons:

  • It switches the one listener to HTTPS-only. Anything on your network that talks plain HTTP to HA breaks. In my case that was another service reading HA entities.
  • On HA 2026.10 the http: block in configuration.yaml is ignored. The http integration imports it once into .storage/http, sets yaml_migration_done, and from then on raises a repair issue if the YAML is still there.

So HTTP stays on port 80 and a proxy terminates TLS on 443.

If you only need live video and no motion alerts, you can skip the proxy. See Skip the proxy if you only need video.

Gotcha: on HA under Supervisor the web port is 80, not 8123. See Check the version facts first. Check .storage/http (server_port) or just curl http://<ha-ip>/ before you trust old tutorials.

Setup

Eight steps. Do them in order.

Step 1: Make a local CA and a server certificate

Alternative: use a certificate from a public CA and skip the CA, the phone trust step (Step 5) and manual renewal. I did not test it. See Use a real certificate.

One CA you install on the phones. One server certificate signed by it. The certificate must list every name and address you will use.

Run this on the laptop. The HAOS host shell has no openssl:

mkdir -p ~/homelab-certs/haos && cd ~/homelab-certs/haos # CA, valid 10 years openssl genrsa -out ca.key 4096 openssl req -x509 -new -key ca.key -sha256 -days 3650 \ -subj "/CN=Homelab Local CA" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ -out ca.crt # server key + request openssl genrsa -out haos.key 2048 openssl req -new -key haos.key -subj "/CN=haos.home.arpa" -out haos.csr # extensions: the SAN is what browsers check cat > ext.cnf <<'EOF' basicConstraints=CA:FALSE keyUsage=digitalSignature,keyEncipherment extendedKeyUsage=serverAuth subjectAltName=DNS:haos.home.arpa,IP:192.168.1.50 EOF # sign it, 825 days openssl x509 -req -in haos.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -days 825 -sha256 -extfile ext.cnf -out haos.crt openssl verify -CAfile ca.crt haos.crt chmod 600 ca.key haos.key 

Verify it worked: the last openssl verify prints haos.crt: OK.

I used 825 days, the limit Apple documents for TLS server certificates, as a safe value. Private CAs may be exempt. I did not test a longer period.

Keep ca.key off the server. You need it again only to renew. If you lose it, every device has to trust a new CA.

Copy haos.crt and haos.key into HA's /ssl folder. The NGINX app in Step 2 reads certificates from there. Use the HA shell from the Terminal & SSH app: cat > /ssl/haos.crt, paste the contents of haos.crt from the laptop, end with Ctrl-D, and repeat for haos.key. Check each file: head -1 must print -----BEGIN ... and tail -1 must print -----END .... See Check files after you write them. On my install /ssl is /mnt/data/supervisor/ssl on the host.

The later curl --cacert ca.crt examples assume you run them on the laptop in ~/homelab-certs/haos.

Step 2: Install the NGINX proxy app and point it at the certificate

In the HA UI: Settings, Apps (called Add-ons before 2025), install NGINX Home Assistant SSL proxy from the official store. Open its Configuration tab and set:

domain: haos.home.arpa certfile: haos.crt keyfile: haos.key hsts: "" cloudflare: false use_ssl_backend: false client_max_body_size_megabytes: 1 customize: active: false default: nginx_proxy_default*.conf servers: nginx_proxy/*.conf real_ip_from: [] 

The last three options are the defaults. Enable "Start on boot", then start it.

Gotcha: leave hsts empty. HSTS is per host name, not per port. If you set it, browsers force http://haos.home.arpa to HTTPS and anything still on plain HTTP is locked out.

Gotcha: HTTPS works only with the host name, not the bare IP. A browser does not send a name (SNI) when you type an IP. The app's catch-all server rejects those connections (ssl_reject_handshake on in its nginx template). I tried adding a second server block for the IP. It is never chosen without SNI, so it does not help. Use the name.

Gotcha: if you edit app options through the Supervisor API instead of the UI, the call needs every option, not only the ones you change. A partial body fails with Missing option 'cloudflare'.

Step 3: Add the DNS record

Add a record haos.home.arpa -> 192.168.1.50 to your LAN DNS. On the Pi-hole host, for Pi-hole v6:

pihole-FTL --config dns.hosts '["192.168.1.50 haos.home.arpa"]' 

That command replaces the whole list. Read it first with pihole-FTL --config dns.hosts and include the existing entries in the new array. Pi-hole applies the change without a restart.

The laptop and the old phone must use that DNS server. Mine get it from the router. If yours do not, set it by hand in the Wi-Fi settings.

Verify it worked: getent hosts haos.home.arpa (or nslookup haos.home.arpa) on the laptop returns 192.168.1.50.

Step 4: Tell HA to trust the proxy

Open https://haos.home.arpa/ now. It returns 400: Bad Request. The HA log says:

A request from a reverse proxy was received from 172.30.33.1, but your HTTP integration is not set-up for reverse proxies 

HA must be told which proxy addresses to believe. On 2026.10 this lives in .storage/http, not YAML. Use the HA shell. The Terminal & SSH app has nano and vi.

  1. Stop core: ha core stop.
  2. Copy the file first: cp /config/.storage/http /config/http.bak. Delete the copy when everything works.
  3. Open /config/.storage/http. Inside the existing data -> stable object, add the two keys use_x_forwarded_for and trusted_proxies. Leave every other key alone. The file looks like this (yours may have more keys and other values):
 { "version": 2, "minor_version": 2, "key": "http", "data": { "stable": { "server_port": 80, "use_x_forwarded_for": true, "trusted_proxies": ["172.30.33.0/24"], "ip_ban_enabled": true }, "pending": null, "yaml_migration_done": true } } 

Keep the JSON valid.

  1. While core is stopped, also create the folders later steps need: mkdir -p /config/www /config/packages. HA only serves /local/ for a www folder that existed when core started.
  2. Start core: ha core start.

On a running HA 2026.10 the file exists. If yours is missing or looks very different, you are on another version and this step may not apply. Use the address from your own log line (HA UI, Settings, System, Logs; the UI still works over plain HTTP at this point). 172.30.33.0/24 is the network of the proxy app on my install. The HAOS host itself is 172.30.32.1. They are different networks.

Gotcha: edit the file with core stopped. HA manages .storage files itself, and an edit made while it runs can be overwritten.

Verify it worked:

curl --cacert ca.crt https://haos.home.arpa/ -o /dev/null -w "%{http_code}\n" # 200 curl http://haos.home.arpa/ -o /dev/null -w "%{http_code}\n" # 200 

Both must answer.

Step 5: Trust the CA on the phone

Skip this step if you use a real certificate.

Do this on the iOS 12 phone:

  1. Get ca.crt onto the phone. In the HA shell: cp or paste it to /config/www/ca.crt. On the phone, open http://haos.home.arpa/local/ca.crt in Safari (plain HTTP on purpose, since HTTPS is not trusted yet). Install the profile (Settings, General, Profile). Delete /config/www/ca.crt afterwards. It is a public certificate, but there is no reason to leave it served.
  2. Settings, General, About, Certificate Trust Settings. Switch the CA on. Installing alone is not enough.
  3. Open https://haos.home.arpa/ in Safari. No warning and a lock means it worked.

Gotcha: tapping through a certificate warning lets the page load. It does not make the phone trust the host. Install and enable the CA.

Step 6: go2rtc, the stream and the proxy path

This step uses the WebRTC Camera integration. A visible go2rtc app may be simpler. See Try the go2rtc app instead of the integration.

go2rtc is the part that receives the WebRTC video. HA has its own copy for its camera features. That one is internal. You want a separate one on port 1984 with a stream you define.

Install the WebRTC Camera integration:

  1. HA UI, HACS, menu, Custom repositories. Add https://github.com/AlexxIT/WebRTC as an Integration.
  2. Download it from HACS and restart HA.
  3. Settings, Devices & services, Add integration, WebRTC Camera. It has no options. On my install no further restart was needed for the next check.
  4. In the HA shell: ls /config/go2rtc*. A file like go2rtc-1.9.9 should be there, and curl http://haos.home.arpa:1984/api should answer with JSON.

If the binary does not appear, nothing below will work. I have no tested fallback.

On my install a binary /config/go2rtc-1.9.9 appeared at that moment (its file time matches the time the config entry was created). It runs after every HA start and listens on 1984 (API), 8554 (RTSP) and 8555 (WebRTC). I did not read the integration's code to confirm that it launches the binary. The restart tests below are my evidence.

HA's built-in go2rtc is a different process on other ports. Ignore it.

Declare the stream in /config/go2rtc.yaml (the HA config folder):

streams: old_phone_pet_cam: 

The value is empty on purpose. It makes the stream exist so something can publish into it.

Restart core with ha core restart so go2rtc rereads the file.

Verify it worked:

curl http://haos.home.arpa:1984/api/streams # {"old_phone_pet_cam":{"producers":null,"consumers":null}} 

Gotcha: without the stream in go2rtc.yaml, publishing fails with stream not found. The viewer error streams: unknown error only means the stream exists and nobody is publishing.

Gotcha: do not start a second go2rtc, for example as a Docker container. It loses the race for ports 1984 and 8554 to the one HA starts and logs address already in use, while the first one keeps answering. The setup then looks fine and the second instance does nothing.

go2rtc speaks plain HTTP. The phone's page is HTTPS, so route go2rtc through the NGINX app. With customize.active on, the app includes /share/<customize.default> inside its HTTPS server block, before its own location /. I confirmed this in the app's nginx template.

Create /share/nginx_proxy_default.conf (the Samba share share; host path /mnt/data/supervisor/share/ on my VM):

# go2rtc (host process, plain HTTP/WS on :1984) behind the app's TLS. # homeassistant.local.hass.io = the HAOS host (172.30.32.1), same name the app uses to reach HA. location /go2rtc/ { proxy_pass http://homeassistant.local.hass.io:1984/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $http_host; proxy_read_timeout 3600s; } 

Set customize.active: true in the app options (same options as Step 2) and restart the app. If /go2rtc/ then returns 502, the name does not resolve in your app version. Replace it in the file with the HAOS host address (172.30.32.1 on mine).

homeassistant.local.hass.io is the name the app itself uses to reach HA. Inside the app it resolves to the HAOS host (172.30.32.1 on mine), where go2rtc listens on all interfaces. $connection_upgrade is defined by the app's own nginx config, so the include can use it.

Verify it worked:

curl --cacert ca.crt https://haos.home.arpa/go2rtc/api/streams 

Gotcha: go2rtc on 1984 and 8554 has no login. Anyone on your LAN can see the stream and the API. Do not forward those ports.

Gotcha: wss://haos.home.arpa:1984 will not work. go2rtc has no TLS on that port. Everything goes through /go2rtc/ on 443.

Step 7: The camera page

Generate one webhook id on the laptop. You use the same value three times: in cam.html, in pet_cam.yaml (Step 8) and in the test curl (Step 8):

python3 -c 'import secrets; print(secrets.token_hex(16))' 

Put the file below at /config/www/cam.html (use the HA shell). The www folder was created in Step 4. HA then serves the file at https://haos.home.arpa/local/cam.html. Replace <WEBHOOK_ID> with your id:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>iOS 12 Pet Cam Streamer</title> <style> body { font-family: -apple-system, sans-serif; background: #000; color: #fff; text-align: center; margin: 0; padding: 20px; } video { width: 100%; max-width: 400px; background: #222; border-radius: 8px; transform: scaleX(-1); } .status { margin: 15px; font-size: 14px; color: #aaa; font-weight: bold; } </style> </head> <body> <h3>iOS 12 Camera Broadcaster</h3> <!-- These elements MUST exist before the script can find them --> <video id="localVideo" autoplay playsinline muted></video> <div id="status" class="status">Loading configuration...</div> <div id="motion" class="status">Motion: waiting</div> <script> var STREAM_NAME = "old_phone_pet_cam"; // WHIP publish: SDP offer POSTed over plain HTTPS (iOS 12 Safari cannot open // websockets to this server). Path is proxied to go2rtc by the HA nginx app. var WHIP_URL = "/go2rtc/api/webrtc?dst=" + STREAM_NAME; var pc = null; function setStatus(text, color) { var el = document.getElementById("status"); el.innerText = text; if (color) { el.style.color = color; } } function waitForIce(peer, ms) { return new Promise(function(resolve) { if (peer.iceGatheringState === "complete") { resolve(); return; } var done = function() { peer.onicegatheringstatechange = null; resolve(); }; peer.onicegatheringstatechange = function() { if (peer.iceGatheringState === "complete") { done(); } }; setTimeout(done, ms); }); } function post(url, body) { return new Promise(function(resolve, reject) { var x = new XMLHttpRequest(); x.open("POST", url); x.setRequestHeader("Content-Type", "application/sdp"); x.onload = function() { if (x.status >= 200 && x.status < 300) { resolve(x.responseText); } else { reject(new Error("WHIP HTTP " + x.status)); } }; x.onerror = function() { reject(new Error("WHIP network error")); }; x.send(body); }); } var localStream = null; var retryTimer = null; // Any failure (go2rtc/HA restarting, network drop) tears the peer down and retries. function scheduleRetry(reason) { setStatus(reason + " - retrying in 5s...", "#f44336"); if (pc) { try { pc.close(); } catch (e) {} pc = null; } if (retryTimer) { return; } retryTimer = setTimeout(function() { retryTimer = null; connect(); }, 5000); } function connect() { setStatus("Connecting to go2rtc engine..."); var peer = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); pc = peer; localStream.getTracks().forEach(function(t) { peer.addTrack(t, localStream); }); peer.oniceconnectionstatechange = function() { if (pc !== peer) { return; } var st = peer.iceConnectionState; if (st === "connected" || st === "completed") { setStatus("LIVE: BROADCASTING TO HOME ASSISTANT", "#4caf50"); } else if (st === "failed" || st === "disconnected" || st === "closed") { scheduleRetry("ICE " + st); } }; peer.createOffer() .then(function(offer) { return peer.setLocalDescription(offer); }) .then(function() { setStatus("Gathering ICE candidates..."); return waitForIce(peer, 3000); }) .then(function() { setStatus("Sending offer to go2rtc..."); return post(WHIP_URL, peer.localDescription.sdp); }) .then(function(answerSdp) { setStatus("Got answer, connecting media..."); return peer.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp: answerSdp })); }) .catch(function(err) { if (pc === peer) { scheduleRetry("Error: " + (err && err.message ? err.message : err)); } }); } function startStream() { var video = document.getElementById("localVideo"); setStatus("Requesting device camera access..."); var constraints = { audio: false, video: { facingMode: "environment", width: 640, height: 480, frameRate: 15 } }; navigator.mediaDevices.getUserMedia(constraints) .then(function(stream) { localStream = stream; video.srcObject = stream; connect(); startMotion(); }) .catch(function(err) { setStatus("Camera error: " + (err && err.message ? err.message : err), "#f44336"); }); } // ---- Motion detection (runs on the phone, no server load) ---- // Frame-diff on a tiny grayscale canvas. On motion, POST the local-only HA // webhook; the HA automation sends the push + snapshot. var MOTION_WEBHOOK = "/api/webhook/<WEBHOOK_ID>"; var MOTION = { W: 64, H: 48, PIXEL: 25, FRACTION: 0.02, INTERVAL: 500, COOLDOWN: 60000, WARMUP: 8000 }; var motionPrev = null, motionLast = 0, motionStart = 0, motionRetryAt = 0Source:https://dev.to/pehota/a-free-pet-cam-from-an-old-iphone-no-app-just-webrtc-5072

Related posts