Blog

August 15, 2026 · 17 min read

Solo Dev Stack: Running an iOS App, Go Backend, and Kubernetes Alone

The stack boundaries a solo developer settled on while running an iOS app, a Go backend, and self-hosted Kubernetes. It starts with three characters in a note that two code paths read differently.

  • solo development
  • tech stack
  • GitOps
  • Kubernetes

The user wrote “tell me” in a note. One side of the app read it as a notification request, while another side did not. It was a bug where two places interpreted the same three syllables differently.

The cause was a word list. If “tell me” was in the list, it counted as a notification; if not, it did not. “Please tell me” and “Could you tell me?” were not in the list. Instead of expanding the list, I removed the code that made word-based decisions from the core.

Three layers operated alone

I let the model make the decision and got half of it wrong

After removing the word list, there was an obvious next option: ask an on-device model. If the model could decide whether a sentence was an execution request or just a note, it seemed the problem of breaking on every variation would disappear.

It did not.

What I asked Result
Original text only, 8 choices 8/12
Facts included, 3 choices 9/13
Yes/no binary gate 12/16
Same gate expanded to 200 trials 99/200
Rate at which declarative sentences were misclassified as execution requests 73~87%
25 English questions 0 correct
When asked to extract a title 12/12

The first three rows did not look too bad. So I expanded the same gate to 200 trials and it got 99 right. That is a coin toss.

The confusion matrix showed where it broke down. The rate of reading declarative sentences as execution requests was between 73% and 87%. It got none of the 25 questions that came in English right. A question is also logically a request, so “is this a request?” was itself a question with split answers.

The model attached a confidence score above 90 even to wrong answers. Filtering only answers with low confidence cannot catch these errors.

I discarded the test set along with it. When I made 200 sentences, I assumed features the app did not have, such as shopping lists, web searches, and schedule edits. I was measuring sentences nobody used. The product's actual domain is meetings, contracts, estimates, documents, schedules, and notifications.

It got requests with a fixed answer format, such as "Extract a title from this text", right twelve out of twelve times. It got the execution decision wrong 99 times out of 200.

So code determines the order

I first decided what not to ask the model to do, rather than what to ask it to do. Code decides which step runs first, where to go after failure, and whether to ask the user. The model fills one defined slot at each step.

The shape of the slot is fixed in advance. A summary step has one title and several key points. If the model leaves that shape, code rejects the output and asks again. After three failures, I fill the slot by extracting sentences verbatim from the source.

In this structure, the model has no execution authority. Code policy decides whether to create a calendar event, not the model's output. If it cannot interpret a time with certainty, it does not create the event. A wrong event is worse than no event.

I did not base decisions on resources that might be absent

The measurements exposed more problems.

guardrailViolation is the error Apple's model emits when it rejects input under a safety policy. It occurred even for ordinary Korean sentences. If I assume the model always responds normally, the user sees a screen that does nothing.

In Korean, NLTagger and NLEmbedding were unavailable. They are Apple's built-in tools for splitting morphemes and turning sentences into vectors. Translation reports unsupported in the simulator. If a decision depends on resources that may not exist depending on the device and language, the entire feature disappears for users without those resources.

There was an opposite case too. NSDataDetector handled dates and times more accurately than a regular expression I wrote myself. It got all four Korean relative-time cases right. My regular expression failed on “tomorrow morning at 9.” I deleted 100 lines of weekday and month-day regular expressions. Hand-built is not always better.

I put a fallback wherever the app calls a model. The fallback is always the same: leave the failure in the record. Even when a summary cannot be made, the text the user sent remains unchanged.

I do not manage twenty-one services as a list

I counted how far the service count in the cluster I operate alone had grown. After combining two repositories, there were 21 Application declarations. Asking the cluster directly returned 23. Two more were parent cards, root and clab-app-root. When I checked all 23 today, every one was Synced and Healthy.

An Application is a deployment card read by Argo CD. It records which path in which repository goes to which namespace in the cluster. Argo CD continuously compares the card with the real cluster and fixes differences. When the repository is the source of truth, the approach is called GitOps.

With 23 cards, someone has to manage the card list. Every new service means adding another line. If one person maintains that list by hand, eventually one will be missed.

I added one more card that reads the list.

# clab-cluster/argocd/root-app.yaml
kind: Application
metadata:
  name: root
spec:
  source:
    path: argocd/applications     # 이 디렉터리를 목록으로 읽습니다
    directory:
      recurse: false              # 바로 아래 파일만 봅니다
  syncPolicy:
    automated:
      prune: true                 # 선언에서 빠지면 클러스터에서도 지웁니다
      selfHeal: true              # 손으로 바꾼 것은 되돌립니다

root is not a service. It reads the argocd/applications directory and makes the cards inside it children. Adding one file adds one service. I do not need to edit the parent. This structure is called app-of-apps.

prune: true is double-edged. Delete a card and it is deleted from the cluster too, so the repository alone tells me what is running now. But accidentally deleting a file also removes a service.

I do not memorize twenty-one names

Instead of memorizing card names, I grouped them by what they do.

Group Services Namespace
Observability kube-prometheus-stack, observability monitoring
Certificates cert-manager, cluster-issuer cert-manager
Access control oauth2-proxy, oauth-gateway auth, monitoring
Mail stalwart, postal mail, postal
Product justsend-backend, justsend-console, justsend-ops, justsend-render, justsend-share-web, justsend-web justsend-platform
User front door discourse justsend-platform
Back office plane plane
My own baguette, fp-router, hello-clab, posy-weather-web, test-agents each

Each group fails differently. If observability dies, I cannot tell that something else died. If certificates expire, browsers show a warning. If access control is breached, the back office is exposed. If mail stops, user inquiries disappear.

When one person operates the cluster, these eight groups divide that person's early-morning hours. I did not treat all eight as the same kind of thing.

I pin chart versions

Cards that use charts made by others have pinned versions.

# clab-cluster/argocd/applications/kube-prometheus-stack.yaml
spec:
  source:
    repoURL: https://prometheus-community.github.io/helm-charts
    chart: kube-prometheus-stack
    targetRevision: 66.3.1        # 최신이 아니라 이 버전
  destination:
    namespace: monitoring

If targetRevision is empty, the cluster follows when the chart is updated. Monitoring changes to a new version while I sleep. cert-manager is pinned to v1.16.2, and oauth2-proxy to 7.9.2.

From then on, upgrading a version is a human decision. One commit remains, and there is somewhere to roll back to.

I split the server into three and left a fallback in the app

The backend is not one piece either. Three components are deployed independently.

What Where Deployment
Public API api.example.com StatefulSet
Back-office console admin.example.com Deployment
Renderer Called only inside the cluster Deployment, 3 replicas

StatefulSet is a deployment style in which each instance has its own storage. Deployment can replace an instance with any other one. The console has no state, so it uses the latter.

The renderer has no external address. The backend calls the cluster-internal address listed in the JUSTSEND_RENDER_URL environment variable. It handles the heavy work of launching a browser and drawing a web page. Each of the 3 replicas renders only one page at a time, and one page took 2.7 to 4.5 seconds. The backend's upper wait limit is 60 seconds. Putting it at the same address as the public API would let outsiders call this heavy work directly.

When a user sends a link, I need its title and body. Where to do this was a decision.

There is one endpoint. Sending {"url": "..."} to the reader extraction endpoint returns title, site, markdown, and hero_image_url.

// platform/backend/internal/reader/reader.go:30-31
const maxBody = 4 << 20      // 4MiB까지만 읽고 잘랐다고 표시합니다
const minBodyRunes = 80      // 80자보다 짧으면 본문으로 인정하지 않습니다

Without an upper bound, one outside server could fill our memory. The response might be 100MB, or it might never arrive.

// platform/backend/internal/reader/reader.go:147-152
client.Timeout = 10 * time.Second   // 10초 안에 못 받으면 포기합니다
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
    if len(via) >= 3 {              // 리다이렉트는 세 번까지
        return statusError(http.StatusGatewayTimeout, "UPSTREAM_TIMEOUT", "too many redirects", nil)
    }
    if err := e.validateURL(req.Context(), req.URL); err != nil {

I included this code for its last line. The address is checked again every time a redirect is followed.

Calling the address a user sent as-is could call into our cluster. For example, http://10.0.0.1 or http://localhost:6379. This problem, where a server fetches its own internal network on someone else's behalf, is called SSRF.

Checking only the first address does not stop it. An attacker can provide a normal address and make that server redirect to http://10.0.0.1. That is why I check every hop.

// platform/backend/internal/reader/reader.go:198-203
if u.Scheme != "http" && u.Scheme != "https" { ... }   // 다른 프로토콜은 거부
if u.User != nil { ... }                                // 주소에 계정 정보가 붙으면 거부
if port != "" && port != "80" && port != "443" { ... }  // 다른 포트는 거부

Restricting ports to 80 and 443 is what makes this check effective. Even a carefully maintained private-IP list is bypassed if http://internal-host:6379 gets through. After resolving DNS, I also reject an IP in a reserved range. I do not decide from the domain name alone.

If the server cannot, the app fetches instead

Putting the work on the server does not mean I can trust only the server. The app falls back through three steps.

Order What it does Time limit
1 Ask the server's reader
2 Fetch the HTML directly in the app 8 seconds
3 Try rendering the page in a hidden web view 15 seconds

If the server says “this address is rejected,” the app does not fall back locally. Rejection is the conclusion, so there is no reason to retry. If the server cannot be reached or returns an empty result, it moves to step 2. If the body is shorter than 100 characters, it may be a JavaScript-rendered page, so it moves to step 3.

I opened this feature to users without accounts

I decided not to require registration just to paste one link. The app receives a temporary token based on the device identifier.

The token is limited to 50 requests per 24 hours. This keeps a free server from being used as someone else's crawler. At the same time, it is a product limit. Someone who saves many links can exceed 50 in one day.

When I operate alone, I also make the decision to raise that number. The same person has to weigh server cost against abuse risk.

One app is actually five pieces

When I say “build an iOS app,” what comes to mind is a screen. Open the build settings and there are five pieces.

# ios-prod/app/project.yml
packages:
  justsend-core:
    path: JustSendMemoryCore      # 원격 버전이 아니라 저장소 안의 경로
targets:
  JustSendKit:    { type: framework }        # 공통 코드
  JustSend:       { type: application }      # 본 앱
  JustSendShare:  { type: app-extension }    # 공유 시트에서 뜨는 것
  JustSendWidgets: { type: app-extension }   # 홈 화면 위젯
  JustSendTests:  { type: bundle.unit-test } # 테스트

Three of them are installed for users: the main app, the share extension, and the widget. The other two exist to build those three.

What I call it What it actually contains
One app I picture only the main app
Three products The main app, share extension, and widget
Five pieces Those three plus the shared framework and tests

The problem is not that the number grows from three to five. The problem is that all three see the same data.

Three places write to the same file

When a user shares a link from Safari, the share extension runs. It is a different process from the main app. The widget is separate too. If each keeps its own data, a note saved from the share sheet does not appear in the app.

App Group is an iOS feature that lets multiple apps and extensions from one developer use the same folder. Putting all three in the same App Group lets them open the same SQLite file.

What happens if all three write to that file at once? What happens if the user opens the app while the share extension is saving? If the app was updated but the widget is still on the old version, two schemas are opening the same file.

I had to answer these questions before drawing screens. If I answer them later, I already have to migrate the user's stored data.

I keep the core inside the repository

The line under packages is the place most often misunderstood in this project.

packages:
  justsend-core:
    path: JustSendMemoryCore      # remote 아님, 저장소 안의 디렉터리

path does not point to a tag in a remote repository. It is a directory inside the app repository. I split the AI summarization core into a separate repository, but the copy included in the current build is the one inside the app repository.

If the core has a defect, I could add a workaround in the app. To prevent that temptation, I set one rule: a core defect is fixed in the core. If the app overrides it, the next person has to read both places.

Why I made this split and what I lost goes in the next article.

I handed customer support to six agents

When I build alone, what comes after building is the problem. App Store reviews arrive in 16 languages. Mail arrives. Questions appear in the community. Pods die in the cluster.

These four are repetition, not judgment. So I handed them to agents.

Name Role Channel What it does
haram Lead care-lobby Briefings, escalation
ria Reviews app-reviews Responds to reviews in 16 languages
daon Community community Operates Discourse, handles reports
woojin Mail mail-desk First response to mail tickets
sena Metrics analytics-kpi App analytics, KPI reports
taesan Operations cluster-ops Cluster status, initial incident response

Each one has its own tools. ria sees App Store Connect, daon sees Discourse, woojin sees the mail server, and taesan sees Kubernetes.

Permissions live outside the prompt

The measurement from the first section matters again. A model that is wrong half the time while attaching confidence above 90 is not made safe by writing “you may only read” in its prompt.

So I blocked access where tools are registered. scopedServer exposes only role-appropriate tools to each agent. Write tools are not registered for sena at all. No matter what the prompt says, an absent tool cannot be called.

The lead and insight roles are read-only. The lead and the metrics agent only read and report other people's state.

Actions that leave traces outside the system have another layer. Answering a review, sending mail, or restarting a pod uses tools disabled by environment variables and, even when enabled, requires human approval.

I wrote down what I did not hand off

Next to each agent's work, I listed the work that remains with a person.

What the agents do What the person does
Draft review replies and check language Decide what the product promises
Community replies and report handling Product policy
First response to mail Change the backend API
Metrics report Decide which metrics to watch
Read cluster status Change deployment manifests

The right column is the rest of this series. I did not hand off the core contract, the app's data boundaries, or the contents of deployment cards.

People execute secrets and cluster changes. An agent can create a manifest, but cannot apply it.

Constraints caused by being alone determined the design

Looking back at the decisions so far, they have one thing in common. Every one came from “one person cannot watch two places at the same time.”

I stopped having a person manage the list because there is only one person who could forget an item. I pinned chart versions because there is no one to watch what gets upgraded while I sleep. I did not let the model determine the order because there is only one person to undo a wrong execution.

The record left in Plane points the same way. IOSPROD-9 split the work into Backend/Runtime and Product Design/SwiftUI tracks. It fixed the shape of the data the screen would receive first, then let each track implement it. Even if one person alternates between the two tracks, a contract written first prevents them from diverging when they are combined.

IOSPROD-11 placed the contract between the actual execution path and the design document side by side and compared them. That work prevents a type that exists only in the document and not in code from being mistaken for a product path.

Splitting boundaries has a separate cost

Splitting boundaries did not reduce the amount of work.

Because I split deployment units, I have to keep watching which repository commit moves which card. Because the backend and console deploy independently, rollbacks are independent too. Because I separated the public API from the internal transformation path, I have to follow their call relationship.

Because I handed repetition to agents, I have to read what they did. Because I narrowed permissions, a person has to take over whenever something is blocked.

The cost did not disappear. I traded it for fewer times being woken up at dawn.