Universal Links on iOS without running a web server

You don't need to run nginx or Cloudflare to ship Universal Links on your iOS app. Three serverless-friendly options — and the gotcha that catches every shortcut.

You’re building an iOS app. You’ve decided you need Universal Links — the proper iOS way to make https://yourapp.com/share/abc open in your app instead of Safari. You read Apple’s documentation and discover that to make this work you need to:

  1. Host an apple-app-site-association file at https://yourapp.com/.well-known/apple-app-site-association
  2. Serve it over HTTPS with Content-Type: application/json
  3. Make sure it’s not behind any redirects
  4. Renew the SSL cert before it expires
  5. …keep that infrastructure running for as long as your app is in the App Store

If you’re an indie iOS developer who has never run a web server, that list is dispiriting. The good news: you have several “no web server required” options. The bad news: each has a non-obvious failure mode that bites.

Here are the three real options, ranked from most-DIY to least.

Option 1: GitHub Pages

GitHub Pages is free, fast, and HTTPS-by-default for username.github.io subdomains and any custom domain you point at it. Sounds perfect.

The catch: you can’t set per-file Content-Type headers. GitHub Pages serves the apple-app-site-association file as application/octet-stream by default, which Apple’s swcd daemon silently rejects. Even though the file is reachable, your Universal Links won’t work.

The workaround that actually works:

  1. Create a repo called yourdomain-aasa (or any name).
  2. Add a single file at .well-known/apple-app-site-association with no .json extension.
  3. Add a _headers file at the repo root — but only Cloudflare Pages and Netlify honor _headers. Plain GitHub Pages does not. So this only works if you use GitHub Pages with a Cloudflare Pages or Netlify deployment in front of it, not GitHub Pages standalone.

In practice: if you want fully-DIY GitHub Pages hosting, you’ll need to combine it with Cloudflare Pages or Netlify (both free) to get the headers right. That’s two services to configure correctly instead of one.

Option 2: Firebase Hosting

Firebase Hosting is also free for low-traffic sites and does let you set per-file Content-Type headers via firebase.json:

{
  "hosting": {
    "appAssociation": "NONE",
    "public": "public",
    "headers": [
      {
        "source": "/.well-known/apple-app-site-association",
        "headers": [
          { "key": "Content-Type", "value": "application/json" }
        ]
      }
    ]
  }
}

Two important details:

  • "appAssociation": "NONE" is critical. By default, Firebase Hosting auto-generates an AASA file from your Firebase Dynamic Links configuration. Setting it to NONE disables that auto-generation so your hand-written file isn’t overwritten.
  • The file must literally be named apple-app-site-association with no extension and placed in your public/.well-known/ directory.

Then run:

firebase deploy --only hosting

This works. The downside is you’ve now got a Firebase project to manage (billing, deploys, IAM) for what amounts to serving one tiny static file.

Option 3: A purpose-built service

This is the path we built OrbitKit for. You configure your App IDs and path patterns in a dashboard, point your DNS, and the AASA file is served at both canonical paths with the correct headers, automatic SSL, no Cloudflare bot-mode interference, and a 128 KB size guard. Free tier covers a single app.

The trade-off versus DIY: monthly cost (we charge $5/mo per app once you go beyond the free tier) in exchange for no infrastructure to think about, a config UI instead of editing JSON by hand, and the same domain serving your privacy policy / support page / data deletion page (which Apple also requires).

The gotcha that breaks every shortcut

Whichever route you pick, the most-common shortcut failure is the same: Apple needs the file at the domain root, not at a subdirectory.

https://yourapp.com/.well-known/apple-app-site-association works. https://shared-host.com/yourapp/.well-known/apple-app-site-association does not.

This rules out hosting on a path you don’t control — like a Notion page, a username.github.io/yourapp/ URL, or a shared subdomain. You either need to own the domain (and configure DNS for it) or use a service like GitHub Pages / Firebase / OrbitKit that lets you point a custom domain at it.

For Apple Developer Program reasons, this also means Universal Links don’t work on localhost or simulator — you can configure them, but they won’t activate. You need a real domain to test end-to-end.

What “without a web server” really means

In the strict sense, every option above involves some server — Apple insists on serving the file over HTTPS. The thing you’re avoiding is operating a server: no SSH access, no nginx config, no certbot renewals, no firewall rules. You’re delegating those to GitHub, Firebase, OrbitKit, or another managed service.

Pick the one that matches what you already do:

  • You live in the GitHub ecosystem and don’t mind setting up Cloudflare Pages → Option 1.
  • You already have a Firebase project for backend → Option 2.
  • You don’t want to think about infrastructure at all and you also need a privacy policy / support page hosted on the same domain → Option 3.

Verifying it works (regardless of host)

Once your file is up, this curl confirms it:

curl -sI https://yourapp.com/.well-known/apple-app-site-association

You’re looking for:

  • HTTP/2 200 (not 301, 302, or 404)
  • content-type: application/json exactly
  • No redirect chain (no Location: header)

Then in your iOS app’s Signing & Capabilities, add the Associated Domains capability with:

applinks:yourapp.com

Build, install on a real device (not simulator), send yourself a link via Notes, and long-press it. If you see “Open in <App Name>” as an option, Universal Links are working.

If they’re not, our Universal Links debugging checklist walks through every common failure mode and how to diagnose it.

Further reading