{"id":509,"date":"2026-08-25T22:04:19","date_gmt":"2026-08-25T20:04:19","guid":{"rendered":"https:\/\/nax.cz\/?p=509"},"modified":"2026-08-25T22:04:19","modified_gmt":"2026-08-25T20:04:19","slug":"what-i-learned-vibe-coding-an-ios-app-from-scratch-including-spinning-up-my-own-backend-on-linode","status":"publish","type":"post","link":"https:\/\/nax.cz\/?p=509","title":{"rendered":"What I Learned Vibe-Coding an iOS App from Scratch \u2014 Including Spinning Up My Own Backend on Linode"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><em>Posted on August 25, 2026 \u00b7 Personal Projects<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A few weeks ago I started an experiment. I wanted to see how far I could get building a real, production-ready iOS application using GitHub Copilot as my primary development partner, mostly guiding, reviewing, and occasionally correcting. The result is <strong>Outdoor Fun Hunt<\/strong>, a GPS-based treasure hunt app that lets you create and play location-based hunts outdoors with friends and family. I plan to use it this week at the camp site where I meet lot of my friends.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post is about what I actually learned in the process. Not the polished narrative, but the honest one \u2014 including the part where I had to spin up my own backend server from scratch, which turned out to be one of the most educational pieces of the whole project.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Idea: Vibe-Coding as a Real Workflow<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Vibe-coding, for those who haven&#8217;t come across the term yet, is the practice of describing what you want to build in natural language and letting an AI coding assistant generate most of the implementation. You stay in the driver&#8217;s seat \u2014 you define requirements, review output, catch bugs, make architectural decisions \u2014 but you&#8217;re not writing every line yourself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I was curious whether this approach could produce something genuinely usable, not just a toy prototype. The app needed a map, GPS-based clue unlocking, photo tasks, a real-time leaderboard, an organiser editor protected by real authentication, and full English and Czech localisation. Non-trivial.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The short answer: yes, it can. The longer answer is where things get interesting.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">What Worked Surprisingly Well<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">SwiftUI and MVVM boilerplate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Copilot is excellent at generating SwiftUI views, view models, and the connective tissue between them. Give it a clear description of what a screen should do \u2014 &#8222;a map-dominated player view with a progress card at the bottom and zoom buttons that work after the user has panned&#8220; \u2014 and it produces solid, idiomatic Swift. The Observation framework, async\/await, actor isolation, proper environment injection \u2014 it gets all of that right most of the time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tests<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I was impressed by the test coverage it generated. Every service and view model got a matching Swift Testing suite with proper given\/when\/then structure, mock implementations of all injected protocols, and coverage of edge cases I hadn&#8217;t even thought to ask for. Keeping tests passing became a reliable safety net as the codebase grew.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Localisation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Generating a full Czech translation including plural forms, diacritics, and grammatically correct phrasing for every string in the app \u2014 that would have taken me days. It took maybe an hour of prompting and correction. Czech has complex plural rules (1 task \/ 2\u20134 tasks \/ 5+ tasks are all grammatically different) and Copilot handled them correctly once I pointed it in the right direction.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">What Required More Hands-On Work Than I Expected<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">MapKit&#8217;s gesture-stealing<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This was my most stubborn bug. I built zoom in\/out buttons over the map, and they worked fine \u2014 until the user actually panned or pinched. After any manual gesture, the buttons silently stopped responding. The fix turned out to be two separate issues layered on top of each other: MapKit&#8217;s internal <code>MKMapView<\/code> gesture recognisers intercept touches before SwiftUI&#8217;s overlay buttons can get them, and <code>MapCameraPosition.region<\/code> returns nil after a user gesture (the camera position internally switches representation). Neither of these is documented well. You learn them by running into them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Appwrite&#8217;s query format changing between versions<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Appwrite backend I deployed used version 1.9. The query format Copilot generated was the old DSL-string format (<code>equal(\"field\", [\"value\"])<\/code>) rather than the current JSON format (<code>{\"method\":\"equal\",\"attribute\":\"field\",\"values\":[\"value\"]}<\/code>). Every data query returned a 400 &#8222;Syntax error&#8220; until I diagnosed it by testing the API directly with curl. Lesson: always verify generated API integration code against the actual live version of the service you&#8217;re running, not whatever version is best-represented in the AI&#8217;s training data.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Spinning Up Appwrite on Linode: The Full Story<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This deserves its own section because it was genuinely the steepest learning curve of the project \u2014 and also the most satisfying thing I figured out.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why I needed a backend at all<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The app started entirely local \u2014 hunt data stored in a JSON file bundled with the app, progress saved to the device&#8217;s Documents folder. That&#8217;s fine for a single player, but the feature I most wanted was a <strong>live leaderboard<\/strong>: multiple teams competing on the same hunt, seeing each other&#8217;s scores update in real time. That needs a shared backend.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Choosing Appwrite<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I evaluated three options: PocketBase (single binary, minimal ops overhead), Appwrite (richer feature set, official Linode Marketplace listing, native Swift SDK), and Supabase self-hosted (Postgres, excellent queries, but a 10-container Docker composition that&#8217;s frankly overkill for a hunt app). I went with Appwrite \u2014 it was in the Linode Marketplace, had a first-class Swift SDK, and its Storage service has chunked resumable uploads built in, which matters for a future photo-upload feature.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Linode Marketplace deploy<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you haven&#8217;t used Linode (now Akamai Compute), the Marketplace experience is not as smooth as I wish. You pick your app, choose a plan \u2014 I used a 4 GB Shared CPU instance, about \u20ac24\/month \u2014 fill in a few parameters, and click Create. Ten minutes later Appwrite is running on a fresh Ubuntu 24.04 box, with Traefik handling TLS automatically once your DNS A record has propagated. Except it took me a while to figure out it was installed to a different location than where Copilot suggested.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The part they don&#8217;t tell you clearly in the documentation: the first-time setup requires you to find the <em>installed location<\/em> of the Docker Compose project, because the Marketplace StackScript doesn&#8217;t always put it where you&#8217;d expect. The most reliable way I found was asking Docker itself:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker inspect $(docker ps -qf \"name=appwrite\") \\\n  --format '{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}' | sort -u\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That prints the exact directory. <code>cd<\/code> there and you have everything: the <code>.env<\/code> file with all configuration, and <code>docker compose<\/code> commands to apply changes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Anonymous authentication as the player login model<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">One design decision I&#8217;m particularly happy with: players don&#8217;t need to create an account. They just type a team name. Under the hood, each install creates an anonymous Appwrite session (persisted via cookie, valid for a year) and all progress is stored against that session&#8217;s identity plus the team name string. No sign-up friction, no passwords to forget. The leaderboard aggregates by team name, not by account. Clean and simple for the use case.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Organisers \u2014 the people building hunts \u2014 do have real email\/password accounts, because they need write access to the hunts collection and that has to be properly gated. Switching between the anonymous gameplay session and the organiser session required a small but non-obvious trick: Appwrite refuses to create a new session while one is already active. You have to explicitly delete the current session first, then authenticate with email\/password. Once I understood that, the implementation was straightforward.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The thing nobody mentions about Appwrite&#8217;s console sign-up<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you set <code>_APP_CONSOLE_WHITELIST_ROOT=enabled<\/code> in the <code>.env<\/code> (which the Marketplace script does by default), you can only create one admin account \u2014 ever \u2014 and it has to be the very first sign-up. If you don&#8217;t do this immediately, you get a confusing &#8222;sign-up is restricted&#8220; error with no obvious path forward. The fix is to temporarily set <code>_APP_CONSOLE_WHITELIST_ROOT=disabled<\/code>, create your account, then re-enable it. Knowing this upfront would have saved me twenty minutes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Infrastructure as code with Terraform\/OpenTofu<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I also wrote a complete Terraform configuration for the Linode deployment \u2014 variables, firewall rules, a persistent data volume that survives instance rebuilds, DNS management, and a cloud-init script that installs Appwrite non-interactively. If you want to tear down and rebuild the whole thing reproducibly, it takes one command.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Side note: HashiCorp changed Terraform&#8217;s licence to BUSL, and Homebrew removed the formula as a result. If you try <code>brew install terraform<\/code> today you&#8217;ll get an error. Use <code>brew install hashicorp\/tap\/terraform<\/code> from HashiCorp&#8217;s own tap, or switch to <strong>OpenTofu<\/strong> (<code>brew install opentofu<\/code>), the open-source fork that&#8217;s fully compatible and available in Homebrew core.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Honest Take on Vibe-Coding<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The workflow genuinely accelerates development. Features that would have taken me a day to implement took an hour. The architectural decisions \u2014 feature-based folder structure, protocol-first services, offline-first caching, proper Swift concurrency \u2014 were well-reasoned and consistent throughout the codebase because I could describe principles once and have them applied everywhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But it&#8217;s not magic, and it&#8217;s not a replacement for understanding what you&#8217;re building. The moments where things went wrong were always the moments where I accepted generated output without understanding it well enough to know what questions to ask. The MapKit zoom bug took longer to fix than it should have because I initially trusted the generated solution rather than digging into why the buttons stopped working after a pan gesture. The Appwrite query format issue was invisible until I tested against the real API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The meta-skill that vibe-coding requires isn&#8217;t typing less \u2014 it&#8217;s <strong>knowing enough to recognise when something is subtly wrong<\/strong>, even when it looks plausible. That&#8217;s still on you.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">What&#8217;s Next<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The app is in review with Apple now. After launch I want to add photo upload \u2014 the schema and storage bucket are already provisioned on the backend, and the client code has a clean seam ready for it. I also want to move the backend from HTTP to HTTPS (currently running with an ATS exception, which is embarrassing), and add a simple in-app notification when you&#8217;re knocked off the top of the leaderboard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want to follow along, or if you&#8217;re curious about using the app for your own events \u2014 a scout camp, a family reunion, a corporate team-building day \u2014 I&#8217;d love to hear from you.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Tags:<\/strong> iOS, SwiftUI, Vibe-Coding, GitHub Copilot, Appwrite, Linode, Swift, Treasure Hunt, Indie Dev<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Posted on August 25, 2026 \u00b7 Personal Projects A few weeks ago I started an experiment. I wanted to see how far I could get building a real, production-ready iOS application using GitHub Copilot as my primary development partner, mostly guiding, reviewing, and occasionally correcting. The result is Outdoor Fun Hunt, a GPS-based treasure hunt [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-509","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/nax.cz\/index.php?rest_route=\/wp\/v2\/posts\/509","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nax.cz\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nax.cz\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nax.cz\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nax.cz\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=509"}],"version-history":[{"count":1,"href":"https:\/\/nax.cz\/index.php?rest_route=\/wp\/v2\/posts\/509\/revisions"}],"predecessor-version":[{"id":510,"href":"https:\/\/nax.cz\/index.php?rest_route=\/wp\/v2\/posts\/509\/revisions\/510"}],"wp:attachment":[{"href":"https:\/\/nax.cz\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=509"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nax.cz\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=509"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nax.cz\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=509"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}