There Is No Laptop Anymore
• 7 minutes read
There is no laptop anymore. I say what I want to Claude from my phone, and it does the rest. I haven't opened a terminal in months, and I still ship code often. The machine doing the programming is a Claude Code environment running in the cloud, one I've configured once: it comes with Nix already installed, carries my own git identity so commits land as mine and not some anonymous bot, and drops into my repo with everything already wired up.
The bootstrap itself is almost nothing. Claude's cloud environments now come with Nix preinstalled, so I don't install it myself anymore. A short script runs once when the environment starts: it adds direnv on top of Nix, and flips one Claude Code setting so it stops appending a "Co-Authored-By: Claude" trailer to my commits. My name and email are already the author and committer; I don't need the tool announcing itself in every commit message too. That's the entire setup. Everything else, the toolchains, the secrets, the deploy scripts, lives in the repos themselves, not in this script.
That last part is the one I care most about. Nix is what makes the
environment disposable. The monorepo ships one flake.nix
declaring the exact toolchain it needs: Node, pnpm, whatever CLI
scripts I've wrapped as first-class commands. Paired with direnv, the
moment the agent cds into the project, that toolchain is
just there. No global installs, no "works on my machine," no drift
between what I had last week and what I have today. The environment is
cattle, not a pet. If the cloud session disappears, the next one
reconstructs itself from the repo in seconds.
Secrets travel the same way, with sops and age. Every secret lives in
a sops-encrypted file checked into the repo like any other file: the
Cloudflare account ID and API token, the passphrase that encrypts the
infrastructure state, the Google sign-in credentials, an SSH key. A
small .sops.yaml says who can decrypt them: two age
public keys, one for my own machine and one for Claude's cloud. The
matching cloud private key sits in the environment's settings as an
environment variable, SOPS_AGE_KEY, set once when I
configured it. That one variable is what grants the cloud session
access to everything. A fresh git clone there can decrypt
the secrets, so it can deploy, migrate a database or change the
infrastructure without asking me for a credential. There is no key
file to fetch and no secrets manager to sign in to first, and direnv
loads the credentials the moment the agent is in the directory. I
never paste an API token into a chat, and I never keep one in an
untracked .env file I'll eventually lose. If I ever need
to cut the cloud off, I remove its key from .sops.yaml,
re-encrypt, and rotate the secrets.
The infrastructure is in the repo too. All my sites now live in one
monorepo, and next to the apps there is an infra/ folder
with OpenTofu, the open-source fork of Terraform. It declares the
Cloudflare side of everything: the domain and its DNS records, each
app's custom domain, the D1 databases, KV namespaces and R2 buckets,
email routing, the secrets each Worker reads, and even the API token
the deploys use. The secret values come from the same sops files, and
the OpenTofu state is stored encrypted in an R2 bucket. So the repo
describes the whole system, from the DNS records down to the last line
of code. With a clone and the age key, the agent can plan and apply a
change to the infrastructure the same way it changes a component, and
nothing about the setup lives only in a dashboard or in my head.
The stack on top of that is deliberately boring. TypeScript, Vite or Astro, React for whatever UI the project needs. Static output. The "backend," when a project needs one, is a Cloudflare Worker: same language, same repo, same deploy step. There's no server to patch, no OS to update, no process to keep alive. I write a function, it runs on the edge when someone requests it, and it costs nothing when nobody does. Workers is just the compute half of it, too. The moment a project needs to hold onto something instead of just responding, it's the same Cloudflare account, no new vendor to sign up for: R2 for object storage, D1 for an actual SQL database, KV for simple key-value state, all with the same shape of pricing, generous free tiers, nothing to provision ahead of time. Past the free tier it does scale with usage like anything else, but the limits are generous enough that a personal project doesn't get near them.
The runtime itself is the part I find genuinely interesting. A Worker
isn't a container spinning up on demand, it's a V8 isolate, the same
sandboxing technique Chrome uses to keep tabs apart, and it
cold-starts in low single-digit milliseconds instead of the seconds a
container takes to boot. Cloudflare runs that isolate in hundreds of
its own data centers, so a request gets handled by whichever one is
physically closest to whoever made it; there's no origin server
sitting in one region for everyone else to route back to. This site is
the plainest example: the static files themselves are served straight
out of the Worker, no separate CDN bolted on in front. And the same
account covers what used to mean stitching together several separate
services: Cron Triggers for anything that needs to run on a schedule,
Queues for background work I don't want blocking a response, Durable
Objects for the rare case something needs one consistent point of
coordination instead of stateless requests, Workers AI for inference
without picking a model host myself. I haven't needed most of that
yet, but it's sitting in the same account, one wrangler
command away, for whenever a project does.
The frontend half has its own discipline, quieter but just as
deliberate. Colors are never a raw Tailwind palette class in component
code, only semantic tokens like --color-ctext or --color-cbg, defined once and retuned in one place if a
palette ever changes. Every component's tests run through Vitest and
Testing Library with coverage enforced at 100%, not because untested
code is forbidden on principle but because it gives the agent, and me,
a concrete signal that a change didn't quietly break something three
files away. And it's the same TypeScript on both sides of the deploy:
the components rendering a page and the Worker serving it are the same
language, the same repo, the same type-checker run, so a shape change
in one place shows up as a compiler error in the other instead of a
runtime surprise.
Which gets to the part people usually ask about first: what this costs. CA$29.40 a month for the Claude subscription, plus about US$10 a year per domain, registered through Cloudflare. The code itself lives on GitHub, free. The Workers platform running it is on the free tier. That's the whole bill. The cloud environment itself is a real machine, a Firecracker microVM running Ubuntu 24.04 with 4 vCPUs, 15 GB of RAM, and tens of gigabytes of disk, and I'm not billed a cent for any of it. I'm only metered on tokens, and that's the part that still amazes me: actual engineering work, on a real machine, and the cost that scales is model usage, not compute. No servers to rent, no hosting plans to size, no invoice that scales with traffic I'm not getting yet. I used to think of infrastructure as a fixed cost you accept before you've proven an idea is worth anything. Now there isn't one to accept.
That changes what a side project actually is. It used to be an idea plus a weekend spent provisioning a server, wiring up CI, deciding where the database lives, remembering to renew a certificate six months later. Now it's just the idea. I describe what I want, the agent scaffolds it inside a Nix shell that's already correct, and it deploys to a Worker that was never not going to scale. The distance between "I thought of this" and "this is live at a real URL" has gotten small enough that the infrastructure stopped being a decision I make and became a default I inherit.
I don't think this setup is unusual for what it uses. Nix, Cloudflare
Workers, sops, direnv are not new. What's changed is that I no longer
need to be the one operating any of it. The agent reads the same flake.nix and CLAUDE.md I would, and it just
runs the commands. I don't need a laptop's job to shrink down to
anything, because I'm just as often on my phone, saying what I want
and reading back what happened. The actual computer doing the work is
one I don't own, don't patch, and don't pay for beyond the
subscription.
Under the Hood
None of this skips a staging step, it just doesn't need a separate
environment for one. It's a Wrangler CLI flag: build, then wrangler versions upload --preview-alias dev instead of wrangler deploy. That uploads a real Worker Version,
reachable at its own dev-<name>.<subdomain>.workers.dev URL, with
zero effect on production traffic. I look at that URL, and only once
it's right does a second, explicit command promote it to production.
Staging isn't a separate account, a separate deploy target, or a
separate anything, it's an argument.
There's no separate CI service either. When Claude finishes a turn in one of my repos, a stop hook runs first, a script the harness runs before it will let the agent consider the task done, and it runs the actual quality gate: formatter, type checker, linter, unit tests, build, sometimes even a preview deploy. Any of those fails, and it blocks with the reason, and the agent has to fix it and try again. Only once all of that is green does it check the git state itself: uncommitted changes, untracked files, commits sitting local that never got pushed. It's a smaller surface than a real CI pipeline, but it means the same checks that would normally live in a GitHub Actions file run right next to the agent doing the work, before it's even allowed to say it's done. Roughly:
stop hook (sketch)
#!/bin/bash
# runs after every agent turn, before Claude is allowed to stop
set -euo pipefail
check() {
if ! "$@" 1>&2; then
echo "Failed, and it must pass before continuing: $*" >&2
exit 1 # non-zero blocks the turn from ending
fi
}
check pnpm run format
check pnpm exec tsc
check pnpm run lint
check pnpm run test
check pnpm run build
if [ -n "$(git status --porcelain)" ]; then
echo "Uncommitted changes found. Commit and push before finishing." >&2
exit 1
fiThe cloud environment's own setup is just as small. This runs once, when the environment starts:
cloud environment bootstrap script
set -euxo pipefail
nix profile add nixpkgs#direnv
nix --version
direnv --version
mkdir -p ~/.claude
[ -f ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.json
jq '.includeCoAuthoredBy = false' ~/.claude/settings.json > ~/.claude/settings.json.tmp \
&& mv ~/.claude/settings.json.tmp ~/.claude/settings.json
Then direnv takes over per project. Every repo's .envrc
loads the Nix dev shell and decrypts secrets the moment I cd into it:
.envrc
#! /bin/sh
watch_file flake.nix
watch_file flake.lock
source <(nix print-dev-env)
export CLOUDFLARE_ACCOUNT_ID=$(sops --decrypt --extract '["cloudflare-account-id"]' secrets.yaml)
export CLOUDFLARE_API_TOKEN=$(sops --decrypt --extract '["cloudflare-api-token"]' secrets.yaml)
And the flake declaring that shell is where the actual commands live:
a dev server, a CI-equivalent script, a deploy, a preview-deploy, so
the agent runs nix run .#deploy instead of memorizing a
project-specific incantation. A trimmed-down version of the shape
every repo uses:
flake.nix
{
inputs = {
nixpkgs.url = "nixpkgs";
systems.url = "github:nix-systems/default";
};
outputs = inputs: let
supportedSystems = import inputs.systems;
forEachSystem = inputs.nixpkgs.lib.genAttrs supportedSystems;
overlayForSystem = system: (nixpkgs: _: rec {
mysite = {
ci = nixpkgs.writeShellApplication {
name = "ci";
runtimeInputs = [nixpkgs.nodejs];
text = ''
pnpm exec tsc
pnpm run format
pnpm run lint --fix
pnpm run lint
pnpm run build
pnpm test
'';
};
frontend = nixpkgs.writeShellApplication {
name = "frontend";
runtimeInputs = [nixpkgs.nodejs];
text = "pnpm run dev";
};
deploy = nixpkgs.writeShellApplication {
name = "deploy";
runtimeInputs = [nixpkgs.nodejs];
text = ''
pnpm exec tsc
pnpm run build
pnpm exec wrangler deploy
'';
};
deployPreview = nixpkgs.writeShellApplication {
name = "deploy-preview";
runtimeInputs = [nixpkgs.nodejs];
text = ''
pnpm exec tsc
pnpm run build
pnpm exec wrangler versions upload --preview-alias dev
'';
};
shell = nixpkgs.mkShell {
packages = [
mysite.ci
mysite.deploy
mysite.deployPreview
mysite.frontend
nixpkgs.nodejs
nixpkgs.pnpm
nixpkgs.sops
];
};
};
});
nixpkgs = forEachSystem (system:
import inputs.nixpkgs {
inherit system;
overlays = [(overlayForSystem system)];
});
in {
devShells = forEachSystem (system: {
default = nixpkgs.${system}.mysite.shell;
});
};
}Note: The thoughts, substance, and structure of this article are mine. AI worked on the prose, word choice, and connectors to improve clarity and articulation.