Skip to content
Kevin Amado
← Thoughts

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
fi

The 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.