← Free GuidesPart 6 of 6, the beach chair series →
How To Deploy From Your Phone: The Build
September 7, 2026
I wrote about why I did this in the piece about being chained to a computer. This is the build.
By the end you can review work, merge it, ship it to your production server, and prove it landed — from a phone, with no laptop involved and no server key on the device in your hand.
Every command here ran against a live box. Every failure I describe actually happened to me.
Before you start
You need three things. If you have all three this takes an afternoon.
- A server you control. Mine is a twelve-dollar-a-month DigitalOcean box. Anything you can
SSH into and run a service on works.
- Your code in a repo the server can read. The server needs a read-only deploy key so it can
pull its own updates. Most setups already have one.
- A web service already running on that server. You are adding one route to something that
exists, not standing up a new app.
If your deploys today are "SSH in and pull," you have everything you need.
The two walls, and why neither is your fault
A desktop session cannot leave the machine it is pointed at. A desktop Claude Code session is bound to a folder on your computer. From a phone you can see that a scheduled run fired and read the line proving it, but you cannot open the conversation, because out there no conversation exists. No amount of restarting fixes this. Nothing is broken. It is what "runs in this folder on this machine" means, and hours disappear into that misunderstanding.
The deploy key lives wherever you deploy from. If shipping means SSHing from your office, the key is in your office, and "ship it" quietly means "be at that desk."
Both walls come down the same way: stop treating your laptop as the way in, and give the server a front door of its own.
The shape of it
Three parts, none of which depend on an app behaving:
- A cloud session, not a desktop one. No folder, no machine, so it opens on your phone, your
desktop and a browser as one continuous conversation.
- The server updates itself. It already has read access to your code. It just needs
something to tell it when.
- Verification is a measurement. A public endpoint reports the commit actually running. You
read it until it matches what you merged.
---
Step 1 — Create the session on your phone
Open the app, start a new session, point it at your repo.
Do not create it as a scheduled routine. Routine-spawned sessions are a different class and may never appear in your session list at all. That was my original failure and it is not configurable from your side. Start a normal session, by hand, once.
Step 2 — Put the charter in the repo, not in the chat
Write the session's standing instructions into a file in your repo and have it read that file on startup.
A charter pasted into a chat box is accurate for about a day. A charter in the repo is updated by the same changes that update the system. Keep only durable things in it — who does what, the standing orders, the merge rule, how to deploy, and the mistakes that have actually bitten you. For anything live, point at the source instead. A document that repeats live state starts lying the moment somebody ships.
Then your paste is one line:
You are my remote command deck. Read docs/STANDING_BRIEF.md first — it is your charter. Then report what is open and what needs my decision.Step 3 — Mint a key that does exactly one job
The obvious move is to hand the session the API token your server already has. Don't.
When I searched for mine, that same string was also my dashboard login and the seed signing the unsubscribe links in my email. Three doors, one key.
The deploy door gets its own key, and that key can do one thing: ship the main branch. If it leaks, the worst anyone can do is redeploy code that is already public. It rotates in one command and breaks nothing else.
That generates the value, replaces any old line, restarts, and prints it once. Writing the command so it generates its own value is the point. An instruction that says PASTE_VALUE_HERE will end up in a config file as the literal words PASTE_VALUE_HERE, and you will find out weeks later as an unexplained 401.
Step 4 — Build the door
This is the part most write-ups skip, so here is the whole thing.
The auth check. Constant-time comparison, and an unset key authorizes nobody rather than everybody. Security mistakes have a direction and this is the direction it has to fail in.
The route. Note what is missing: the request body is never read. No branch, no path, no options. It always ships origin/main.
Why systemd-run and not just running it. The deploy restarts the very service answering this request. Work done in-process would kill its own reply mid-flight and report a failure that did not happen. Handing it to a transient unit means systemd owns it and it survives the restart.
Why 202 and never 200. 202 means started. 200 would mean done, and this endpoint cannot know that. The design admits what it does not know.
The update script keeps its own rails:
Fast-forward only matters more than it looks. If someone edited files on the server by hand, a merge would quietly bury that work. --ff-only refuses and tells you.
The health endpoint, unauthenticated, reporting the commit actually running:
Two shas, not one, because of a failure nobody could see: the files moved and nothing restarted, so the deploy looked done while the running process was still on the old code. If commit and on_disk differ, a restart is owed.
Step 5 — Hard-code the URL, keep only the token in the environment
Environment variables do not reliably reach a cloud session's shell. Mine did not, and the first live attempt failed on an empty URL before it ever touched the network — which looks exactly like a server problem and is not one.
Put the address in the command. Put only the secret in the environment.
Step 6 — Ship
7:48am, 30% battery. The 202 only says started. The two matching shas are the proof.
When the commit in the health response is the one you merged, it is live.
Calling the endpoint is not evidence. The reading is.
You are done when all three are true
Run these in order. If any one disagrees, stop and fix it before trusting the setup.
| Test | Expected |
|---|---|
| `curl -X POST https://your.server/deploy` with no header | **401** |
| The same call with a wrong token | **401** |
| The same call with the real token | **202** |
| `GET /health` a minute later | `commit` equals the sha you merged |
| `commit` vs `on_disk` in that response | identical |
Two 401s matter as much as the 202. An endpoint that ships code as root and has never been observed refusing anybody has not been tested.
Where each value lives
The most confusing part of the setup, and a mismatch shows up as a flat 401 with nothing else wrong to find.
| Value | On the server | In the remote session |
|---|---|---|
| The deploy key | yes — it is checked here | yes — same string, sent from here |
| Your code-host token | yes — the automation runs here | no — it does nothing there |
| The server URL | not needed | hard-code it in the command |
The deploy key is the only one that has to match in two places.
Tell it what it cannot do
Your remote session has no shell on the server. No logs, no database, no files. It knows exactly what the health endpoint tells it and nothing more.
Instruct it, in the charter, to say "merged, not yet deployed" rather than guessing, and never to claim something is live on the strength of having called the endpoint. It has to go and read.
An assistant that reports success it did not verify is worse than one that reports nothing, because you stop checking.
Two DNS traps that cost me an afternoon each
These bite when you give the server a public name. Both look like server faults and neither is.
A certificate is not automatic just because DNS resolves. If your edge issues certificates on demand, it may only do so for hostnames your application vouches for. DNS pointed at my server and the name still failed the TLS handshake. The fix was not a certificate command — it was telling the app that the hostname was its own. The first request then issued the certificate.
A wildcard DNS record captures every name you have not claimed. Mine pointed * at the app server, so a subdomain meant for object storage silently resolved to the wrong machine, which serves none of it. Nothing errors. You just get the wrong box. Connect a CDN or object-store subdomain in that provider's own dashboard so it creates the record itself; a specific record beats a wildcard.
And when you move a media host, keep the old one connected forever. Changing the public URL only affects new uploads. Everything already published still points at the old host and is typically cached for a year. Two hosts serving one bucket is the correct end state, not a mess to tidy up.
Three failures worth stealing
Not from this build specifically — from the habit of checking, which is what the whole setup rests on.
A check that tested nothing, and passed. A privacy scan reported zero leaks. It had examined zero names, because the data lived under a different key. An empty result is not proof of absence. It is proof the instrument ran.
A test that never runs is green forever. A case added below the runner's entry point is never executed and the suite stays cheerfully green. Search the output for your new case's own label before you trust the count.
A rule that counted the wrong word. My merge gate required every check to report "success." Then CI got smarter and began reporting "skipped" for checks that did not apply, and the gate waited forever for a word that was never coming. Count success or skipped.
All three are the same failure wearing different clothes: something reported success without demonstrating it. That is the thread through this entire build, and it is why the health endpoint exists.
The rule that costs the most to learn late
A secret that appears in a chat log is burned. Not "rotate it later." Rotate it now.
The narrow key exists precisely so that when it happens — and it will — the blast radius is one redeploy of already-public code instead of your dashboard and your email.

