For a long time, my server projects followed the same loop: pull the repository on my laptop, make changes with Codex or an IDE, git push, open a terminal, SSH into the server, git pull, build, restart, and inspect logs. There is nothing inherently wrong with that workflow. It is simple and very understandable.

The problem appears later, when a project becomes something you maintain for months. The development environment, the local checkout, the SSH session, and the production release path all end up attached to one computer.

I recently tightened the deployment path for my own blog until the production flow looked like this:

browser / local development / cloud coding agent
                        ↓
                      GitHub
                        ↓
                 GitHub Actions
                        ↓
               Self-hosted Runner
                        ↓
                build / validate
                        ↓
                 release directory
                        ↓
                  current symlink
                        ↓
                    production

The useful part is not “I never need a local computer again.” It is that the development entry point can change while the production exit stays stable. I can work from a Mac, review a task from a phone, or continue a cloud coding task in the browser. The durable pieces are the Git history, the review state, and one deterministic production workflow.

While turning my notes into this article, I checked the architecture against the current GitHub and OpenAI documentation and against the workflow that is actually running. Two claims needed to be narrowed: connecting normal ChatGPT to GitHub does not automatically give it repository write access, and my deployment is not a magical “every failure rolls back automatically” system.

GitHub’s current Self-hosted Runner reference is here: https://docs.github.com/en/actions/reference/runners/self-hosted-runners

A Self-hosted Runner is not GitHub SSHing into your server

It is easy to picture the model as “a push reaches GitHub, then GitHub logs in to the VPS and runs a script.” That is not how the runner works.

GitHub documents the Self-hosted Runner application as software running on your own machine that connects outward to GitHub and receives job assignments. The host needs outbound HTTPS connectivity, with the core communication using port 443.

Once a VPS is registered as a runner, it behaves more like a long-running worker:

GitHub
  ↑  runner establishes the connection
  │
Self-hosted Runner on VPS
  │
  ├─ checkout
  ├─ npm ci
  ├─ test / build
  └─ local deploy

The workflow routes a job to that worker by labels. For a public example, I can reduce mine to:

jobs:
  deploy:
    runs-on: [self-hosted, linux, prod]

That is materially different from my older SSH/SCP model. With external CI, the build happens somewhere else and the result has to be pushed into the VPS. With a runner on the VPS, the job already executes there. After the build, the workflow can call a local deployment script without maintaining another layer of CI-to-server SSH keys, SCP destinations, and remote shell commands.

This changed how I think about the pieces. GitHub is the control plane and durable state. The VPS is the execution plane. As long as the trusted runner is online, it matters much less which device initiated the code change; production still goes through the same workflow.

It also makes the repository the real source of truth. A runner workspace is no longer “the main code on the server.” It is a temporary execution workspace for one job. The durable things are commits, PRs, workflow runs, and releases.

The browser can be a development entry point, but “connected to GitHub” does not mean “can write GitHub”

This distinction is easy to blur.

OpenAI’s current GitHub connection documentation separates the standard ChatGPT GitHub app from write-capable coding workflows. The normal connection is for reading, searching, and analyzing repositories you authorize. The Help Center explicitly says that generating, editing, and pushing code directly to GitHub is a Codex workflow rather than a capability of the standard read-only GitHub connection.

Official explanation: https://help.openai.com/en/articles/11145903-connecting-github-to-chatgpt

So I would not draw the architecture like this:

ChatGPT Web
   ↓
directly edits GitHub

A more accurate picture is:

browser / phone / desktop
           ↓
write-capable coding workflow
Codex Cloud / controlled agent
           ↓
branch / commit / PR
           ↓
GitHub

Codex Cloud is what makes the “continue development when my laptop is not around” part practical. OpenAI’s current documentation says cloud tasks run on OpenAI-managed computers and can be started or continued across supported surfaces; the task can also continue while the local computer is asleep.

Reference: https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan

I still would not describe this as “the web has no token limit” or “there are no usage limits.” Plans, workspace policies, and product limits can change. The durable advantage is simpler: the coding state no longer has to live inside one Mac terminal session.

That is also why I now think of Codex less as one model window and more as one possible development entry point. The repository, environment, task state, and verification chain are what let the work survive switching devices.

If you want more context on that shift, I wrote about it in why the newer Codex feels more like a development environment.

I only started to trust “develop from any device” after production had one GitHub exit

Being able to edit code from a browser is useful, but it is not enough. If the last release step is still “go back to the Mac and SSH into the server,” then the production dependency has not actually moved.

The more important part of my current setup is that production accepts trusted main changes through one workflow.

I checked the workflow that is running today. At a high level, it does this:

main push
  ↓
checkout
  ↓
runtime / directory checks
  ↓
npm ci
  ↓
syntax checks
  ↓
build
  ↓
capture previous release
  ↓
local atomic deploy
  ↓
post-deploy SEO verification
  ↓
submit sitemap

This is not only an architecture diagram. On October 7, 2026, I checked a completed production Actions run where Build, Atomic Deploy, Post-Deploy SEO Verification, and sitemap submission all completed successfully. That matters to me because it proves the release path really did move out of an ad-hoc SSH session and into a repository workflow.

The deployment script uses a small release layout:

/srv/app/
├─ releases/
│  ├─ 20261007-abc1234/
│  └─ 20261007-def5678/
└─ current -> releases/20261007-def5678/

A new build is copied into its own release directory first. A temporary symlink is then atomically moved into place as current. Nginx or another web server can keep pointing at current instead of at a transient build workspace.

I also tightened one statement from my original notes. It is tempting to summarize the design as “a failed release automatically rolls back,” but the executable workflow is more specific:

  • if build or pre-deploy validation fails, the production switch never happens;
  • if the post-deploy SEO verification fails, the workflow has explicit logic to point current back to the previous release;
  • that does not prove that every possible failure after the symlink switch has an automatic rollback path.

That precision matters. Operations diagrams often become stronger than the code they describe. If I want broader automatic recovery later, I need to define the failure classes and implement those paths explicitly rather than relying on the words “atomic deploy.”

My earlier Markdown + GitHub Actions publishing workflow solved a smaller problem: how content gets online automatically. This architecture answers a wider one: no matter where development starts, which path is allowed to reach production?

The biggest Self-hosted Runner trap is that it really executes code on your machine

Putting the runner on the VPS is convenient precisely because the job can access the local filesystem, network, and deployment paths. That is also the risk.

GitHub’s security documentation is very direct here. A Self-hosted Runner is not guaranteed to be a fresh, disposable virtual machine for every job. Untrusted workflow code can persistently compromise the host. GitHub therefore strongly discourages using self-hosted runners with public repositories and warns about fork/PR paths even for private or internal repositories.

Official security guidance: https://docs.github.com/en/actions/reference/security/secure-use

That made me reread my own workflows. Production deployment is triggered by trusted main updates, which is the boundary I want. But content-PR validation currently also runs on the same Self-hosted Runner.

In my current private-repository, trusted-author setup, that risk is controlled. If I ever open the repository or allow a broader set of contributors to submit PRs, I would not keep running arbitrary PR code on the production runner.

The split I would want is closer to this:

untrusted / public PR
        ↓
GitHub-hosted runner
or isolated disposable validation runner

protected main
        ↓
production Self-hosted Runner
        ↓
deploy

For a larger team, I would also restrict the production runner group to the repositories and workflows that actually need it.

This is why “the AI no longer needs the VPS SSH key” is only part of the security improvement. Removing direct SSH credentials from an agent is good. But if the same agent can change a workflow that later executes arbitrary shell commands on the production host, the privilege did not disappear; it moved.

The real boundary is broader: who can modify workflows, who can merge to main, which events can hit the production runner, what directories the runner user can write, and how much privilege Secrets carry.

Local Codex and SSH did not disappear; they moved into better roles

None of this makes local Codex, a local checkout, or SSH obsolete.

They solve the part that a browser control plane handles poorly: live machine state.

This is how I now divide the work:

ScenarioCloud / GitHub workflowLocal Codex + SSH
Continue a coding task across devicesGreat fitDepends on the current machine
Code history and PR reviewGreat fitGitHub is still canonical
Standard build / test / deployGreat fitShould not be manual every time
Inspect docker ps / journalctlIndirectGreat fit
Database, disk, network diagnosisIndirectGreat fit
Emergency production interventionCan assistSSH is more direct
Auditable releasesBuilt around runs and commitsManual SSH is weaker

A browser or phone works well as a control plane: decide what should change, inspect a diff, review CI, and trigger a workflow that is already defined. The local terminal is closer to an observability and debugging plane: which process is stuck right now, which container keeps restarting, why the disk is full, or which Nginx config is actually loaded.

I am not trying to delete SSH either. I prefer to demote it to a break-glass path. Routine releases should not depend on it, but when production is unhealthy I still want a direct way into the machine. Any emergency fix should eventually come back to GitHub as code or workflow state instead of becoming a server-only change known by one laptop.

If you already have a local Markdown + GitHub Actions setup, my earlier publishing workflow is the simpler starting point. If you are still deciding between a static site and a traditional CMS, Astro vs WordPress is closer to that architecture decision.

The model I care about now is this:

browser / Codex / local IDE   → development entry
GitHub                        → durable code + review state
Self-hosted Runner            → trusted automated execution
SSH                           → deep diagnosis + emergency access

Devices can change. Agents can change. The production server can change later too. As long as code state, deployment rules, and verification logic stay in GitHub, the workflow does not disappear with one computer.

That is the real value I see in ChatGPT/Codex + GitHub + Self-hosted Runner. It does not hide the terminal. It removes as many “this must be done manually on this exact laptop” steps as possible from the normal path.