Arthis.Land

Deploying a Static Site Without a Deploy Button

Shared hosting gives you SSH and a document root, so the release is a script you wrote and the checks you were disciplined enough to add.

There is no platform here, only a document root

Managed hosts hand you a git integration, an immutable release directory and a swap between two requests. Shared hosting hands you an SSH port, a home directory and public_html. Everything the platform would have decided is now a decision you make explicitly and keep correct while tired.

arthis.land deploys to Hostinger from GitHub Actions over rsync: push to main → npm ci → npm run build → sanity-check the output → snapshot the live site into dated backups → rsync --delete into the document root → confirm HTTP 200. The value is entirely in what each step refuses to let through. It has been reliable for months, which is not the same as correct — the flaw I have not fixed is near the end.

The workflow, trimmed to the parts that matter

name: Deploy

on:
  push:
    branches: [main, staging]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Build (regenerates rooms + bricks config first)
        env:
          SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          SUPABASE_PUBLISHABLE_KEY: ${{ secrets.SUPABASE_PUBLISHABLE_KEY }}
        run: npm run build

      - name: Sanity checks
        run: |
          test -d dist || { echo "dist missing"; exit 1; }
          test -s dist/index.html || { echo "index.html missing/empty"; exit 1; }
          BUNDLE=$(ls dist/assets/app-*.js | head -n1)
          grep -q "$(basename "$BUNDLE")" dist/index.html \
            || { echo "index.html does not reference the built bundle"; exit 1; }

      - name: SSH key
        run: |
          install -m 700 -d ~/.ssh
          echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Backup live site (keep 7)
        run: |
          ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" '
            set -e
            mkdir -p ~/.deploy-backups
            tar -czf ~/.deploy-backups/$(date +%Y%m%d-%H%M%S).tgz \
              -C ~/public_html --exclude=city --exclude="Assets made" .
            ls -1t ~/.deploy-backups/*.tgz | tail -n +8 | xargs -r rm --
          '

      - name: Upload
        run: |
          rsync -rlptz --delete --checksum --human-readable \
            --exclude-from=.rsyncignore \
            -e "ssh -p $SSH_PORT" \
            dist/ "$SSH_USER@$SSH_HOST:~/public_html/"

      - name: Confirm
        run: |
          curl -fsS -o /dev/null -w '%{http_code}\n' https://arthis.land/ | grep -q '^200$'

Two details are load-bearing. concurrency with cancel-in-progress: false queues a second push instead of racing two rsyncs into one directory; and the backup runs before the upload, because a backup taken afterwards is a backup of the bad deploy.

Why the build regenerates two files before it bundles anything

npm run build is not just Vite. Two generators run first, and both exist because the alternative is a runtime network request on a path that should not have one.

The first scans city/viewer/StreamingAssets/Rooms/ and writes rooms.config.js. Rooms are folders — the folder name becomes the URL, the trailing number in the .glb filename decides which brick it lands on. With the filesystem as source of truth, adding a room is dropping a folder in place; a hand-written config drifts within a week.

The second queries Supabase and writes bricks.config.js and bricks.snapshot.json. That is why the wall never hits a database on page load — the build-time snapshot step turns a 200–300 kB gzipped query per visitor into one fingerprinted file a CDN edge answers without touching the origin.

The snapshot step must never fail the build on a network error. If Supabase is slow, rate-limited or down, the build keeps the snapshot already committed to the repo and carries on. A stale wall beats a blocked deploy: "your data is an hour old" is invisible to visitors, and "you cannot ship a hotfix because a third party is having an afternoon" is not.

What the step does owe you is honesty in the log. It prints which path it took: snapshot: refreshed 412 rows, or snapshot: network error, reusing committed snapshot (age 3h). Silent fallbacks are how you end up three weeks later wondering why nothing has changed since a Tuesday.

The rsync invocation, flag by flag

rsync -rlptz --delete --checksum --human-readable \
      --exclude-from=.rsyncignore \
      -e "ssh -p $SSH_PORT" \
      dist/ user@host:~/public_html/

.rsyncignore is not clutter

The Unity WebGL viewer and the source assets behind it come to about 326 MB. They are gitignored — the 326 MB that never goes through git is uploaded separately and changes maybe once a month. They live inside the document root, and they are not in dist/. Which gives you the sharpest edge in the pipeline: --delete removes everything on the server absent from dist/, and 326 MB of the live site is absent from dist/ by design.

# Uploaded separately, never present in dist/ — --delete would wipe them
city/
Assets made/

# Staging lives inside production's document root
staging/

# Server-owned, not build output
.htaccess
.well-known/
error_log

Two lines there are the only thing between a routine deploy and a thirty-minute re-upload of the Unity build over a domestic connection. They look like clutter; someone tidying the repo would delete them without a second thought and the deploy would still go green — the site would simply lose its rooms. Config whose job is to prevent something never announces itself: no output, no test, no visible effect. Write the comment saying what breaks without it, because the file cannot.

Sanity checks: what each stage guards against

StageWhat it guards againstIf it fails
npm ciDependency drift — installing a different tree than the one tested locallyBuild aborts; nothing has touched the server
dist/ exists and is non-emptyA build that "succeeded" with a config error and produced nothingAbort. Without it, rsync syncs an empty directory and --delete erases the site
index.html references the built bundleA stale or hand-edited entry point naming a hash that no longer existsAbort. Catches the same breakage the non-atomic deploy causes, before it ships
Route parity guardA clean URL declared in vite.config.mjs but not .htaccess — works on localhost, 404s liveAbort. This has happened, which is why the guard exists
Dated backup, keep 7A deploy with no way backAbort before upload. A deploy you cannot undo is a gamble
rsync --delete with exclude listOrphaned bundles accumulating in the document rootPartial upload; the site sits mid-state — see the atomicity section
HTTP 200 confirmationA transfer that went fine and left the site 500ingWorkflow goes red. It does not auto-revert; it tells you to

None of these check behaviour; they check structure. Green means the shape of what shipped is plausible, not that the wall pans smoothly. There is no test suite, and treating a passing build as a passing feature ships regressions with confidence.

Rollback has to be boring

Before rsync runs, the workflow tars the live document root (minus the excluded 326 MB) into ~/.deploy-backups/YYYYMMDD-HHMMSS.tgz and prunes to the last seven — a week of active days, a few hundred megabytes, enough that a regression noticed on Thursday traces to Monday.

Rollback is then one SSH command: extract the chosen tarball back over public_html. Deliberately cruder than reverting the commit and redeploying — in an incident you want the shortest path to the last known-good bytes, not a four-minute build that might fail for a new reason. Revert the commit afterwards. One caveat: HTML ships with s-maxage=120, so a rollback is not instant at the edge either — two minutes, same as a deploy, because the cache headers this ships cut both ways.

Staging inside production's document root

The staging branch deploys to staging.arthis.land, a subdomain Hostinger maps to a folder inside production's document root. That costs nothing and needs no second hosting plan, which is the whole appeal. What it costs instead: staging inherits production's .htaccess chain, so a header tested there is not always what production applies; staging must be excluded from production's rsync and production from staging's, or each deploy deletes the other; and the parent directory is reachable from the staging hostname, so "staging is private" is a claim about HTTP auth, not the filesystem. A separate account fixes all three and has not been worth the money yet, which I would rather write down than pretend the arrangement is clean.

Secrets

Four values live in Actions secrets: the SSH private key, the known-hosts entry, the Supabase URL and the publishable key. The deploy key is a dedicated ed25519 pair used for nothing else, so revoking it is one authorized_keys edit.

Pinning known_hosts matters more than it looks. The usual shortcut, StrictHostKeyChecking=no, hands CI's credentials and your entire site to whatever answers on that port. Pin the host key and let the connection fail if it changes.

The publishable key is in CI for convenience, not secrecy — it ships to every browser by design and Row Level Security is the actual control. The service_role key must never enter the repo or the workflow: it bypasses RLS entirely, and a key in a workflow file is a key in every fork's build.

The flaw: deploys are not atomic

rsync --delete writes directly into the live document root. For the seconds it runs the site is in a mixed state: some files new, some old, some deleted and not yet replaced. A visitor who requests index.html inside that window can get the new HTML, which references app-a91f3c.js, and then request a bundle that is not on disk yet. They get a 404 and a blank page. No retry, no degradation, no error boundary helps — the application never started.

At current traffic this is invisible: the window is seconds, deploys are infrequent, and I have never caught it in a log. That is what makes it dangerous. The bug is real, the mechanism is understood, and the only thing hiding it is low traffic. At a million page views a day, a five-second window is thousands of broken loads per deploy. Scaling does not introduce this bug. It reveals it.

The fix is well known and nothing clever substitutes for it. Rsync the new build into a sibling directory — public_html.new — verify it there, then swap it in with an atomic rename, or point a symlink at the new release and flip the symlink. Both are single filesystem operations: every request is served entirely by the old release or entirely by the new one, with no moment in between.

The reason it is not done here is unglamorous: it needs a host that lets the document root be a symlink, or lets you rename directories at that level, and this shared plan reliably permits neither. That is a hosting constraint, not a preference. The deploy is unsafe at scale and the fix is blocked on infrastructure I would have to change.

What you can do today, without a better host

The window cannot be closed with rsync alone, but ordering makes its consequences far less likely. Three passes instead of one:

  1. Upload the new fingerprinted assets first, with no --delete. Hashed filenames never collide with the old ones, so the pass is purely additive: the server holds both releases' JS, CSS and images at once, and nothing a visitor is currently using has moved.
  2. Upload the HTML last. HTML is the only file that names the others. Until the new index.html lands, visitors get the old document pointing at old assets that are all still there; the instant it lands they get the new document pointing at new assets already there. The dangerous ordering — HTML before the bundle it references — never occurs.
  3. Never --delete in the same pass that adds. Prune old assets in a separate, later run — the next deploy, or a scheduled sweep. Deleting a bundle an in-flight visitor's cached HTML still points at breaks that visitor for no benefit, and disk is cheaper than that.

That shrinks the failure window from "the whole transfer" to "the moment one small HTML file is written", which on any sane filesystem is close enough to atomic. It is a mitigation, not a fix. The fix is the swap, and the swap needs a different host.

Which is the shape of deploying without a platform. The work is not making the upload happen; it is enumerating the states the site can be caught in halfway through, then either preventing them or writing down honestly which ones you have decided to live with.

Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.