I can't write code.
That's not quite right. I describe what I want, Claude writes the code, and I focus on what to build. I'm what people call a VibeCoder — someone who builds products with AI, spending their time on product decisions rather than syntax.
I'm building "Arc," a mobile CRM for bodywork therapists, using Expo, React Native, and Supabase. The MVP is 85% done — 28 files, 27 screens, 18 of them functional.
The problem wasn't building it. The problem was getting it onto a tester's phone.
The last mile is the hardest mile
Writing code is fast. Tell Claude "build this screen" and something functional appears in minutes.
But putting that build on a tester's iPhone requires:
- Building with Xcode
- Generating an .ipa file
- Uploading it somewhere
- Telling testers "there's a new version"
For a VibeCoder, this "last mile" is the biggest bottleneck — not writing code, but delivering what you've written.
TestFlight? Apple's review queue adds hours or days. Expo Go? Can't use native modules. EAS Build (cloud)? Monthly subscription for a solo dev feels wrong.
The answer turned out to be DeployGate + a self-hosted GitHub Actions runner on my 13-inch M5 MacBook Air. Push to main, wait ~10 minutes, and every tester gets the latest build at a fixed URL. $0/month. Unlimited builds.
What I wanted
Simple, right? Here's what actually happened — 11 failures before attempt #12 finally worked.
Attempt #1 — Workflow collision
Error: Build failed at 3:54 — crashed on "Embed Pods Frameworks."
Cause: Two workflows triggered on the same push to main — one for DeployGate, one for TestFlight. My Mac's Xcode resources couldn't handle both simultaneously.
Fix: Moved the TestFlight workflow out of the active directory.
mkdir -p .github/workflows-disabled
mv .github/workflows/build-and-submit.yml .github/workflows-disabled/
Attempts #2–3 — Binary files in the repo
Error: Every push uploaded 15MB of binary data.
Cause: A .ipa file and an Apple certificate had been accidentally committed.
Fix: Added them to .gitignore and purged from history.
build-*.ipa
AppleWWDRCAG3.cer
Lesson: build artifacts never belong in a repository.
Attempts #4–5 — Package drift
Error: Same "Embed Pods Frameworks" crash.
Cause: Between sessions, react-native had silently updated from 0.83.2 to 0.83.4, and react-native-worklets appeared as a new peer dependency of react-native-reanimated.
How I found it: Checked out the last known working commit's package.json and rebuilt.
git checkout 89fcbe9 -- package.json package-lock.json
npm ci
eas build --platform ios --profile preview --local
# → Build successful
Fix: Pinned packages to the stable version, then formally added the missing dependency.
Attempts #6–7 — Stale runner cache
Error: Builds pass locally, fail in CI.
Cause: The self-hosted runner's working directory had stale node_modules and Xcode DerivedData from previous runs.
Fix: Added a cache-clearing step to the workflow.
- name: Clean caches
run: |
rm -rf ~/Library/Developer/Xcode/DerivedData/Arc-*
rm -rf /tmp/eas-build-local-nodejs
rm -rf /tmp/eas-cli-nodejs
Attempts #8–9 — Credentials source ignored
Error: Console showed Using remote iOS credentials (Expo server) when I wanted local credentials.
Cause: The EAS_CREDENTIALS_SOURCE=local environment variable was silently ignored by eas-cli 18.5.0.
Fix: Set it directly in eas.json.
"preview": {
"distribution": "internal",
"credentialsSource": "local"
}
Attempt #10 — Missing credentials file
Error: credentials.json does not exist in the project root directory
Cause: credentials.json is in .gitignore (as it should be), so the runner's checkout didn't include it.
Fix: Copied it from the Mac's home directory in the workflow.
- name: Copy credentials
run: |
cp ~/client-crm/credentials.json ./credentials.json
cp -r ~/client-crm/credentials ./credentials
Attempt #11 — Locked keychain
Error: Everything compiled fine. Failed only at the final code-signing step ("Embed Pods Frameworks").
Cause: The self-hosted runner runs as a LaunchAgent without access to the GUI session's keychain.
Fix: Explicitly unlock the keychain in the workflow.
- name: Unlock keychain
run: security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" ~/Library/Keychains/login.keychain-db
Store your Mac login password in GitHub Secrets as KEYCHAIN_PASSWORD.
Attempt #12 — It worked 🎉
✔ Using local iOS credentials (credentials.json)
✔ Archive Succeeded
✔ Successfully exported and signed the ipa file
✔ Uploading to DeployGate...
✔ Distribution page updated
From git push to a tester opening the build on their phone: about 10 minutes.
The final workflow
Here's the complete GitHub Actions workflow that survived all 11 failures:
name: Build & Deploy to DeployGate
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Clean caches
run: |
rm -rf ~/Library/Developer/Xcode/DerivedData/Arc-*
rm -rf /tmp/eas-build-local-nodejs
rm -rf /tmp/eas-cli-nodejs
- name: Install dependencies
run: npm ci
- name: Copy credentials
run: |
cp ~/client-crm/credentials.json ./credentials.json
cp -r ~/client-crm/credentials ./credentials
- name: Unlock keychain
run: security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" ~/Library/Keychains/login.keychain-db
- name: Build .ipa
run: eas build --platform ios --profile preview --local --non-interactive
- name: Upload to DeployGate & update distribution page
run: |
IPA_FILE=$(ls build-*.ipa | head -1)
curl -X POST \
"https://deploygate.com/api/users/${DEPLOYGATE_OWNER}/apps" \
-H "Authorization: Bearer ${DEPLOYGATE_API_TOKEN}" \
-F "file=@${IPA_FILE}" \
--form-string "distribution_key=${DEPLOYGATE_DIST_KEY}" \
--form-string "release_note=$(git log -1 --pretty=%B)"
env:
DEPLOYGATE_OWNER: ${{ secrets.DEPLOYGATE_OWNER }}
DEPLOYGATE_API_TOKEN: ${{ secrets.DEPLOYGATE_API_TOKEN }}
DEPLOYGATE_DIST_KEY: ${{ secrets.DEPLOYGATE_DIST_KEY }}
The DeployGate upload API accepts both the .ipa file and distribution page parameters in a single call — pass distribution_key to update an existing distribution page and release_note to auto-populate release notes from your commit message. Testers access the same fixed URL every time. No new links to send, no app store to check.
Why DeployGate instead of alternatives?
I tried the obvious options first:
- TestFlight: Apple's review queue for the first build of each version can take hours to days. When you're iterating multiple times per day, that's a dealbreaker.
- EAS Build (cloud): Works great, but the monthly cost adds up for a solo developer building across multiple projects.
- Expo Go: Fine for early prototyping, but the moment you add native modules (and you will), it stops working.
DeployGate fit because: no review queue, free tier for indie devs, and an API simple enough to add to a GitHub Actions workflow in one curl command. Testers don't need accounts — they open a link and install.
What I learned
After 11 failures, every lesson is burned in:
-
One workflow per trigger. Two workflows on the same
pushwith shared Xcode resources will collide. -
Pin your packages.
npm cionly protects you ifpackage-lock.jsonhasn't drifted. - Self-hosted runners need manual cache hygiene. Unlike cloud runners, they accumulate state between builds.
-
eas.jsonover environment variables. Some CLI tools silently ignore env vars for credential configuration. - Keychain access is not automatic. LaunchAgent processes can't see your GUI keychain without explicit unlock.
- Keep credentials out of the repo, but script their placement. Copy them from a known location on the build machine.
I'm a solo developer building multiple products — a CRM for bodywork therapists, a learning tool for structural consultants, and a few things I can't talk about yet.
Making mistakes along the way and sharing all of it here. Follow @kazbuildsstaff for real build-in-public, not the highlight reel.



Top comments (0)