DevOps & security
Atomic deploys to shared hosting with GitHub Actions
6 min read
Atomic deploys to shared hosting with GitHub Actions
This site is a Laravel 12 application with a Filament admin panel, and it runs on ordinary shared hosting: SSH access, no root, no containers, no managed deployment service. For a while, deploying meant zipping the project and uploading it by hand. I wanted three things instead: a push to master should end up in production on its own, visitors should never see a half-copied release, and a broken release should undo itself without me being at the keyboard.
The constraints
Shared hosting shapes the design more than any tool choice:
- PHP's
exec()andsymlink()are disabled on the server, sophp artisan storage:linkcannot work. Every link has to be created from the shell. - The web root is a fixed folder owned by the hosting panel. The application lives outside it, and the public entry point simply loads the application's own
index.phpthrough a stable path. - There is no Node.js toolchain I want to depend on in production, so front-end assets have to arrive already built.
- Whatever key the CI uses must not grant a general-purpose shell on the server.
The pipeline
The GitHub Actions workflow has two jobs. test runs on every push and pull request: it installs Composer dependencies on PHP 8.2, creates an .env from the example file and runs the full test suite. deploy depends on test and only runs for master:
pnpm install --frozen-lockfileandpnpm run build, so the compiled assets are produced in CI.- Packaging:
git archiveof the exact commit, plus the generatedpublic/buildfolder, gzipped. - One SSH call that streams the tarball to the server.
git archive --format=tar -o release.tar HEAD
tar -rf release.tar public/build
gzip release.tar
ssh -i ~/.ssh/deploy deploy@server "deploy ${GITHUB_SHA::12}" < release.tar.gz
Before connecting, the job checks that the private key in the secrets is actually a valid key, and the server's host key comes from a pinned known_hosts secret instead of being accepted on first sight.
A key that can only deploy
The deploy key is authorized on the server with a forced command:
restrict,command="~/bin/site-receive" ssh-ed25519 AAAA... github-actions
restrict turns off port forwarding, agent forwarding and terminal allocation. command= means that whatever the client asks to run, the server runs the receive script instead; the requested command only arrives as the SSH_ORIGINAL_COMMAND variable. The script accepts exactly one shape, deploy <commit-sha>, and rejects anything else:
id=$(printf '%s' "${SSH_ORIGINAL_COMMAND:-}" | sed -nE 's/^deploy ([0-9a-f]{7,40})$/\1/p')
[ -n "$id" ] || { echo "usage: deploy <sha>" >&2; exit 2; }
REL="$HOME/apps/site-releases/$(date +%Y%m%d-%H%M%S)-$id"
mkdir -p "$REL"
tar -xzf - -C "$REL"
exec bash "$REL/deploy/activate.sh" "$REL"
If the key ever leaked, the worst it could do is deploy a tarball, which is exactly what it is for.
Releases, shared state and a symlink flip
Every deploy unpacks into its own folder. What must survive between releases, the .env file and storage/, lives in a shared folder, and the live application is a symlink:
~/apps/site -> ~/apps/site-releases/20261023-235857-abc1234
~/apps/site-shared/ .env, storage/
~/apps/site-releases/ one folder per deploy
The activation script prepares the new folder completely before anyone can reach it: it links .env and storage, runs composer install --no-dev --optimize-autoloader, links public/storage to the shared uploads, runs the migrations and the content sync, and caches configuration, routes and views. Only then does it switch:
ln -s "$REL" "$APP.next" && mv -Tf "$APP.next" "$APP"
I avoid ln -sfn for this step because it removes the old link and then creates the new one, which leaves a short moment with no link at all. Creating a temporary link and renaming it over the old one relies on rename(), which replaces the link in a single operation: a request sees either the old release or the new one.
After the switch, the script copies the application's .htaccess into the web root and links any new file under public/ that the web root does not have yet.
Health checks and automatic rollback
Two seconds after the switch, the script requests the Spanish, English and Chinese home pages, the admin login and the sitemap, and expects a 200 from each. If any of them answers differently, it points the symlink back at the previous release and exits with an error, so the GitHub Actions run turns red. If all of them pass, it deletes old releases, keeping the three most recent and never touching the current or the previous one.
The rollback that earned its keep
The first deploy of a larger content refactor is the reason I trust this setup. That release introduced project categories and translatable content. CI was green. On the server, all the migrations ran without errors. Then the check on /es got a 500.
The new code expected at least one project category to exist, and production had none yet: the categories are loaded by a separate Artisan command that reads the prepared content, and I had planned to run it by hand after the deploy. The health check caught the error, the script switched back to the previous release, and the run failed with the message that the new version was not responding and that the migrations had not been undone. The broken release was live only for the few seconds between the switch and the checks.
The fix was not to be more careful next time but to change the order: the content sync now runs inside the activation, right after the migrations and before the health checks. The lesson I took from it is simple. Anything the new code needs in order to render has to happen before the checks, never as a manual step afterwards.
The caveat: migrations are not rolled back
The rollback only moves code. The database stays migrated, and the script says so in its own error message. This is only safe if the previous release keeps working on the new schema, and the migrations run before the switch, while the old code is still serving traffic, so that has to hold anyway.
In the incident above it held because every change was additive: new tables and new nullable columns. That is the rule I follow now. Add first; rename or remove in a later deploy, once no running code depends on the old shape. For anything that rewrites existing data, I take a database backup before pushing. I deliberately did not automate migrate:rollback: undoing schema changes on production data is a decision I want to make myself.
In short
None of this needs special infrastructure. It is a workflow file, three shell scripts, a restricted SSH key and a folder layout. What it buys is a deploy that is either fully live or fully reverted, and a failure I hear about from a red CI run instead of from a visitor.