In this guide 6 sections
Run tests on every pull request. Build once when code merges to main. Copy the build over SSH into a new release folder, run migrations, then switch a current symlink to that folder. Keep the last few releases on disk so a rollback is one command.
Run tests on every pull request
Start with one workflow file under .github/workflows that triggers on pull_request. It checks out the code, installs dependencies with npm ci or composer install and runs the test suite. Nothing else happens yet.
Make the check required in the branch protection settings. A merge button that stays grey until the tests pass is the cheapest safeguard a small team can buy.
Keep this workflow free of secrets. Pull requests from outside contributors run with fewer permissions anyway, and a test job should never need the server key.
Build once when code merges to main
A second workflow triggers on push to main. It builds the app, for example compiled assets for a Laravel project or the dist folder for a NestJS service, and packs the result into a tar archive.
Upload that archive as a build artifact. Deploy from the same file you tested, rather than rebuilding on the server. Otherwise a package that changed between the two builds can break production.
Add a concurrency group so two merges in a row queue up instead of racing each other to the server.
Deploy over SSH into a release folder
Create a deploy user on the server with no sudo rights beyond reloading the app. Generate an ed25519 key pair, put the public half in authorized_keys and the private half in a repository secret. Pin the host in known_hosts.
Lay out the server with three folders: releases, shared and a current symlink. Each deploy unpacks into releases with a timestamp name. The shared folder holds the .env file, uploads and logs, linked into every release.
When the files are in place, switch the symlink with ln -sfn and reload php-fpm or the Node process. The swap is a single step, so visitors never see a half copied site.
- A deploy user without a login shell for people
- Private key stored only as a repository secret
- Timestamped release folders
- Shared .env, storage and uploads linked in
- A curl check against a health URL after the switch
Handle migrations and secrets with care
Run database migrations after the files are unpacked and before the symlink moves. Use the production flag, such as php artisan migrate --force. Stop the deploy if the command exits with an error.
Write migrations so the old release still works against the new schema. Add a column first, ship the code, and remove the old column in a later deploy. That keeps a rollback safe.
Store secrets in GitHub environments, with a required reviewer on production. The workflow reads them at run time, and the application keeps its own values in the server’s .env file.
- Add columns before the code that needs them
- Remove columns one release later
- Environment secrets with a named approver
- No secrets printed in the log
Keep the previous release ready for rollback
Because every release sits in its own folder, going back means pointing the symlink at the previous one and reloading the process. Write that as a small script on the server, and test it once before you need it.
Prune old releases at the end of each deploy, keeping the latest five. Disk space stays under control and the last few versions remain available.
Finish by running the whole pipeline against a staging server this week. Break a test on purpose, then break a migration, and watch how the pipeline stops each time.
Pipeline setup worksheet
This worksheet fits on a single page and has three parts. Part one covers the two workflow files, their triggers and the required checks. Part two records server details: deploy user, key fingerprint, folder paths and reload command. Part three is a rollback card with the script name, the health URL and who may run it.
Open a blank worksheet to printQuestions owners ask
Can I deploy to a small VPS this way?
Yes. The release folder and symlink pattern needs only SSH, a web server and a process manager. It works on a modest virtual server as well as a large one.
Should the server pull the code or should Actions push it?
Pushing from Actions keeps the server free of Git credentials and build tools. The server only receives a tested archive. Pulling suits teams who want the server to build for themselves.
What happens to uploaded files during a deploy?
They live in the shared folder, linked into each release, so a new release never overwrites them. Back that folder up on its own schedule.
How do I deploy a database change that cannot be undone?
Split it across releases. Ship code that handles both shapes, migrate, then remove the old shape later. Take a database backup just before any change you cannot reverse.
