How to Set Up a Staging-to-Production Pipeline on a Single VPS Using Git Hooks
You can run a staging-to-production pipeline on a single VPS by keeping one bare Git repository on the server, giving staging and production their own folders, databases and Linux users, and letting a Git hook build a fresh release whenever you push. A push to the staging branch deploys to staging. A push to main deploys to production, but only for a commit that already passed staging. Releases sit in timestamped folders behind a symlink, so a rollback takes seconds.
We have hosted websites for more than 20 years, over 700,000 of them, across 25 server locations. The deploy mistakes we see most are boring ones. Someone edits a live file over SFTP. Someone copies the wrong folder. Someone forgets a migration. This guide builds a small scripted alternative with no CI platform and no second server.
One honest limit before we start. A single VPS is not high availability. If the machine goes down, both environments go down with it, and the last section deals with what that means for you. The scripts assume a PHP app on Nginx and MySQL. Swap the build and migrate steps for your own stack.
How Does a Staging-to-Production Pipeline Work on One VPS?
It is a repeatable route that carries one Git commit through a staging copy of your app and then into production, all on the same server. Each environment has its own folder, database and credentials. A Git hook does the copying, so nobody touches live files by hand.
Staging and production environment separation
Staging is a rehearsal room. Anything that can break should break there first, which only works if staging cannot reach production data, production credentials or production customers. On one VPS that separation is made of five things: different directories, different databases, different environment files, different Linux users and a different hostname such as staging.example.com.
Keep staging close to production in the ways that matter. Same PHP version, same web server, same extensions. A staging box running PHP 8.2 while production runs 8.3 gives you false confidence, and false confidence is worse than no test at all. Keep it different where it protects you: staging should send no real email, charge no real cards and stay out of search results.
Git repository and branch workflow
The workflow needs only two long lived branches. Feature work branches off main. When a feature is ready, you merge it into staging and push. Once it has been tested there, you move that same commit into main with a fast forward merge and push again. Your main copy of the code can stay on GitHub or wherever you keep it. The VPS is just another remote that receives pushes.
git remote add vps deploy@YOUR_VPS_IP:/srv/app/repo.git
git push vps staging
Fast forward matters more than it looks. A plain merge creates a new commit with a new hash, and that hash was never tested on staging. We use the fast forward rule later to make the server refuse untested code.
Deployment path from code commit to production
Here is the whole journey in order. Each step is a script you will write in the sections below.
- You push the staging branch to the VPS.
- A hook builds a new release folder from that exact commit.
- The deploy script runs the build, runs migrations and flips a symlink.
- A health check confirms the app answers. If it fails, the previous release returns.
- On success, the commit hash is written to a list of commits that passed staging.
- You test staging by hand, then fast forward main to the same commit and push.
- A second hook checks the hash against the list and rejects the push if it is missing.
- Production gets a database backup, a new release folder, a symlink flip and its own health check.
It sounds like a lot for a small site. In practice a small PHP project finishes quickly, and most of the time goes to composer install.
Git hooks as deployment triggers
A Git hook is an executable file inside the repository’s hooks folder that Git runs at a set moment. Two matter here. The pre-receive hook runs before a push is accepted, and if it exits with an error the push is rejected. The post-receive hook runs after the push is accepted. It cannot undo anything, because the branch has already moved.
That difference shapes the design. Put gates in pre-receive, because it can say no. Put deployment in post-receive, because by then the commit is safely stored. Both hooks run on the server as the user who pushed over SSH, which is why that user needs careful limits later on. Keep each hook thin and let it call a deploy script you can also run by hand.
Advantages and limitations of a single-VPS architecture
The advantages are real. You pay for one server, patch one operating system and keep one set of logs. Staging runs on the same hardware, the same kernel and the same web server build as production, so surprises are rare. And you need no extra service to run the pipeline, only Git and Bash.
The limits are just as real. Staging shares CPU, memory and disk with production, so a heavy test on staging can slow your live site. You cannot rehearse an operating system or PHP major upgrade on the machine that is serving customers. And one failure takes out both environments. This setup also needs root level control. Ordinary shared web hosting generally does not give you separate PHP pools or hook driven releases, so this is a VPS technique.
How Should You Structure Staging and Production on a Single VPS?
Give each environment its own directory tree, its own database and database user, its own environment file, its own PHP pool running as its own Linux user, and its own web server virtual host. Share only the things you cannot sensibly duplicate, which are the operating system, the web server and the database server.
Separate application directories
Put everything under /srv/app. Each environment gets a releases folder for built copies, a shared folder for files that survive between releases and, once the first deploy runs, a current symlink pointing at the live release. The bare Git repository, scripts, logs, state files and backups sit beside them.
The commands below also create the users described later in this section. Run them once. Be ready for one small piece of friction. A new group membership does not apply to your current SSH session, so if the deploy user gets permission errors straight after this, log out and back in before you start debugging.
sudo adduser –disabled-password –gecos “” deploy
sudo adduser –system –group –no-create-home stagingapp
sudo adduser –system –group –no-create-home prodapp
sudo usermod -aG stagingapp,prodapp deploy
sudo usermod -aG stagingapp,prodapp www-data
sudo mkdir -p /srv/app/{staging,production}/{releases,shared}
sudo mkdir -p /srv/app/{bin,logs,state,backups}
sudo chown -R deploy:deploy /srv/app
Separate databases and credentials
Create two databases and two database users. The staging user gets rights on the staging database only. The production user gets rights on the production database only. If staging code goes wrong, or a leaked staging password turns up in a screenshot, production is not the casualty.
Staging needs realistic data, but not real customer data. The usual answer is a copy of production with names, emails and addresses scrambled before it goes anywhere near staging. Do that copy as a separate, deliberate job, never as part of the deploy script. A deploy that quietly overwrites a database is a disaster waiting for a typo.
Separate environment variables
Each environment has its own .env file stored in its shared folder, never in Git. Commit an .env.example listing the variable names with fake values so the next developer knows what to fill in. The deploy script links the real file into each new release.
Differences in the file are where staging earns its safety. Production gets live payment keys and real mail settings. Staging gets test keys and a mail sink that catches everything. If you send transactional email, point staging at a throwaway account and keep real sending, such as our MailChannels corporate email, for production only. Nothing erodes customer trust like a test newsletter reaching real inboxes.
Dedicated Linux users and permissions
Use three kinds of user. The deploy user owns the code and runs the hooks. The stagingapp and prodapp users run the PHP processes for each environment. The code is readable by the app users but not writable by them, so a bug in your application cannot rewrite its own source.
That read only rule for code is worth defending. If an attacker finds an upload hole in your app, they can write files only where the app user is allowed to, which should be the uploads folder and nothing else. The split is enforced by PHP-FPM pools. Each pool runs as its own user and listens on its own socket. Notice the small pm.max_children value for staging. That cap is your only protection against a runaway staging test starving production of workers, so set it deliberately.
; /etc/php/8.3/fpm/pool.d/staging.conf
[staging]
user = stagingapp
group = stagingapp
listen = /run/php/staging.sock
listen.owner = www-data
listen.group = www-data
pm = ondemand
pm.max_children = 4
; /etc/php/8.3/fpm/pool.d/production.conf
[production]
user = prodapp
group = prodapp
listen = /run/php/production.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
Nginx or Apache virtual hosts
Each environment needs its own virtual host with its own hostname and its own PHP socket. The staging host points at /srv/app/staging/current/public and adds two protections: HTTP basic authentication and an X-Robots-Tag header that keeps search engines away. Apache users can do the same with a VirtualHost per environment, AuthType Basic and Header set.
server {
listen 443 ssl;
server_name staging.example.com;
root /srv/app/staging/current/public;
index index.php;
auth_basic “Staging”;
auth_basic_user_file /etc/nginx/staging.htpasswd;
add_header X-Robots-Tag “noindex, nofollow” always;
# Add a /health location here: auth_basic off, allow 127.0.0.1, deny all.
location / {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/staging.sock;
}
}
Both hostnames need valid HTTPS, including staging, or your browser tests will not match production behaviour. Our SSL reseller program covers certificates if you need them. Leave /health open to the server itself only, so the deploy script can check the app without credentials and nobody outside can read it.
Shared versus environment-specific resources
Share the operating system, the web server, the PHP version, the MySQL server and the disk. Do not share databases, credentials, upload folders, cache stores, queue names, cron jobs or log files. If you use Redis, give each environment its own database number or key prefix, or a staging flush will wipe production’s cache.
The shared list is also your risk list. Everything on it is a way for staging to hurt production. Watch disk space especially, because every release folder is a full copy of the code. The deploy script below keeps five and removes the rest, which keeps a busy server from slowly filling its disk with old copies.
How Do You Configure Git Hooks for Automatic Production Deployment?
Create a bare repository on the VPS, add a post-receive hook that calls a deploy script, and have the script build a release from the pushed commit, run your build and migration steps, flip a symlink and check that the app answers. Add a pre-receive hook later to keep untested commits out of production.
Creating a bare Git repository
A bare repository holds Git history but no working files, which is exactly what a push target should be. The commands below create it as the deploy user, forbid history rewriting and branch deletion, and make it unreadable to other users on the server.
sudo -u deploy git init –bare /srv/app/repo.git
sudo -u deploy git –git-dir=/srv/app/repo.git config receive.denyNonFastForwards true
sudo -u deploy git –git-dir=/srv/app/repo.git config receive.denyDeletes true
sudo -u deploy chmod 750 /srv/app/repo.git
sudo -u deploy touch /srv/app/state/staging_ok
Run the touch line too, because the pre-receive hook later reads that file and a missing file would reject every push to main. The two receive settings are worth the thirty seconds. They stop a force push from rewriting history behind your deploy log, and they stop someone deleting main by accident. Note that denyNonFastForwards is the server side partner of the fast forward habit from the branch workflow.
Configuring the post-receive hook
Save this as hooks/post-receive inside the bare repository and make it executable with chmod +x. Git feeds the hook one line per updated branch, giving the old hash, the new hash and the branch name. The hook reads each line and sends staging and production to the same deploy script with different arguments.
#!/usr/bin/env bash
# /srv/app/repo.git/hooks/post-receive
set -euo pipefail
while read -r oldrev newrev refname; do
case “$refname” in
refs/heads/staging) /srv/app/bin/deploy.sh staging “$newrev” ;;
refs/heads/main) /srv/app/bin/deploy.sh production “$newrev” ;;
esac
done
The hook ignores every other branch, so pushing a feature branch to the VPS does nothing. Because the hook runs inside your git push, you will see the deploy output in your terminal. The push waits until the deploy finishes. For a slow build you may prefer to run the script in the background, but then you lose that live feedback.
Checking out the production branch
We build every release with git archive rather than checking files out into a working tree. It exports the exact files at one commit into a clean folder, touches no index and moves no HEAD. That means a staging deploy and a production deploy cannot trip over each other’s checkout state, which is a classic way for bare repository setups to fail.
The command in the script is git archive “$SHA” | tar -x -C “$RELEASE”. Notice that the deploy uses the commit hash Git handed to the hook, never the name main or staging. Branch names can move while a deploy runs. A hash cannot, so what you tested is what ships.
Running deployment scripts
This is the whole deploy.sh. Read it top to bottom. It takes a lock so two deploys cannot overlap, checks disk space, builds the release, backs up and migrates, flips the symlink, reloads PHP, checks health and trims old releases. The next section explains each stage in detail.
#!/usr/bin/env bash
# /srv/app/bin/deploy.sh <staging|production> <commit>
set -euo pipefail
ENV=”$1″; SHA=”$2″
case “$ENV” in
staging) GROUP=”stagingapp”; HOST=”staging.example.com”; DB=”appstaging” ;;
production) GROUP=”prodapp”; HOST=”www.example.com”; DB=”appprod” ;;
*) echo “Unknown environment.”; exit 1 ;;
esac
BASE=”/srv/app/$ENV”
REPO=”/srv/app/repo.git”
STATE=”/srv/app/state”
LOG=”/srv/app/logs/deploy.log”
RELEASE=”$BASE/releases/$(date +%Y%m%d%H%M%S)_${SHA:0:7}”
exec > >(tee -a “$LOG”) 2>&1
exec 9>”$STATE/deploy.lock”
flock -n 9 || { echo “Another deploy is running.”; exit 1; }
echo “[$(date -Is)] $ENV deploy of $SHA started by ${SSH_CONNECTION:-local}”
# Checks before anything changes
[ “$(df –output=pcent /srv | tail -1 | tr -dc 0-9)” -lt 90 ] || { echo “Disk is over 90 percent.”; exit 1; }
# Build the release from the exact commit
mkdir -p “$RELEASE”
git –git-dir=”$REPO” archive “$SHA” | tar -x -C “$RELEASE”
ln -s “$BASE/shared/.env” “$RELEASE/.env”
ln -s “$BASE/shared/uploads” “$RELEASE/uploads”
(cd “$RELEASE” && composer install –no-dev –optimize-autoloader –no-interaction)
chgrp -R “$GROUP” “$RELEASE”
chmod -R u=rwX,g=rX,o= “$RELEASE”
# Back up the database in production, then migrate
if [ “$ENV” = “production” ]; then
mysqldump –defaults-file=”$BASE/shared/backup.cnf” –single-transaction “$DB” \
| gzip > “/srv/app/backups/${DB}_${SHA:0:7}.sql.gz”
fi
“$RELEASE/scripts/migrate.sh”
# Switch to the new release in one atomic step
PREVIOUS=””
if [ -L “$BASE/current” ]; then PREVIOUS=”$(readlink -f “$BASE/current”)”; fi
ln -sfn “$RELEASE” “$BASE/current.new”
mv -T “$BASE/current.new” “$BASE/current”
sudo /usr/bin/systemctl reload php8.3-fpm
# Health check, with automatic rollback
sleep 3
if ! curl -fsS –max-time 10 –resolve “$HOST:443:127.0.0.1” “https://$HOST/health” > /dev/null; then
echo “Health check failed. Rolling back.”
if [ -n “$PREVIOUS” ]; then
ln -sfn “$PREVIOUS” “$BASE/current.new”
mv -T “$BASE/current.new” “$BASE/current”
sudo /usr/bin/systemctl reload php8.3-fpm
fi
exit 1
fi
# Remember what passed staging, then trim old releases
if [ “$ENV” = “staging” ]; then echo “$SHA” >> “$STATE/staging_ok”; fi
ls -1d “$BASE”/releases/* | sort -r | tail -n +6 | xargs -r rm -rf
echo “[$(date -Is)] $ENV deploy of $SHA finished”
Two details deserve a mention. The symlink flip uses ln -sfn into a temporary name followed by mv -T, because a rename is atomic while replacing a symlink in place is not. And the script calls composer install, which reads and writes a lot of small files. Storage speed shows up directly in build time, so an NVMe VPS helps here, though your project’s dependency count decides how much.
Setting file ownership and permissions
The two lines in the script after composer install set the release folder’s group to the environment’s runtime group and strip access for everyone else. The code ends up owned by deploy, readable by the app user and invisible to the other environment.
Shared files need the opposite setup. Uploads must be writable by the app user, and the .env file must be readable by the app but not by anyone else on the server. Run these once per environment.
sudo mkdir -p /srv/app/production/shared/uploads
sudo chown prodapp:prodapp /srv/app/production/shared/uploads
sudo chown deploy:prodapp /srv/app/production/shared/.env
sudo chmod 640 /srv/app/production/shared/.env
sudo chmod 600 /srv/app/production/shared/backup.cnf
Never use chmod 777 to make a permission error go away. It does not fix the problem. It hides it, and it hands every user and every process on the server write access to your application.
Recording deployment logs
The script’s exec … tee line sends everything it prints to /srv/app/logs/deploy.log and to the pusher’s terminal. Each deploy writes a start line, any failure and a finish line, all with timestamps and the commit hash. That is enough to answer the two questions you will ask during an incident: what shipped, and when.
tail -n 50 /srv/app/logs/deploy.log
grep “production” /srv/app/logs/deploy.log | grep “started” | tail -n 10
Rotate the log so it never grows without limit. Never turn on set -x in a deploy script that handles secrets, because it prints every command with its variables expanded, and your database password could end up in a log file anyone in the group can read.
# /etc/logrotate.d/app-deploy
/srv/app/logs/deploy.log {
monthly
rotate 12
compress
missingok
notifempty
}
How Do You Move Tested Changes From Staging to Production Safely?
Validate the change on staging, promote the same commit with a fast forward merge, let a pre-receive hook confirm that commit passed staging, then run backups, migrations, restarts and health checks in a fixed order. The pipeline, not your memory, should enforce the order.
Staging branch validation
Validation starts before the push. Run your automated tests locally or in whatever CI you already have. Then push to staging and test the deployed result as a user would: log in, submit a form, check out, upload a file, read the logs for new errors. The deploy script only proves the app answers on /health, which is a far smaller claim than “it works.”
Write the manual checks down. A short checklist taped next to the monitor beats a perfect memory, and it turns a vague “looks fine” into a repeatable test. For anything touching payments or accounts, test with the staging keys and confirm no real email left the server.
Production branch promotion
Promotion is three commands. The –ff-only flag makes Git refuse the merge if main has moved on in the meantime, which forces you to bring staging up to date and test again instead of shipping something nobody has seen.
git checkout main
git merge –ff-only staging
git push vps main
The pre-receive hook below is the gate. During a staging deploy, the script appends the commit hash to staging_ok after the health check passes. When you push main, the hook checks that the new hash is on that list. If it is not, the push is rejected before anything touches production. The gate is only as honest as your health check, so keep that check meaningful.
#!/usr/bin/env bash
# /srv/app/repo.git/hooks/pre-receive
set -euo pipefail
while read -r oldrev newrev refname; do
if [ “$refname” = “refs/heads/main” ]; then
if ! grep -qx “$newrev” /srv/app/state/staging_ok; then
echo “Rejected: $newrev has not passed staging.” >&2
exit 1
fi
fi
done
Pre-deployment checks
The deploy script already checks free disk space and takes a lock. Add whatever else fails cheaply and early for your stack: a syntax check of changed PHP files, composer validate, a test that the .env file exists and a test that every required variable is set. Checks like these cost milliseconds and save you from half finished releases.
Decide what counts as a stop. A failed check should exit the script before any file is built or any database is touched. A warning that scrolls past in a log is not a check. If the answer is “we might ignore it,” it is not a gate yet.
Database migration handling
Code deploys can be undone in seconds. Database changes cannot. That asymmetry decides how you write migrations. Prefer changes that old code can survive: add a column before the code that uses it, and delete a column only in a later release, after nothing reads it. If a migration is backward compatible, rolling back the code needs no database work at all.
The script takes a compressed mysqldump before every production migration, using a credentials file so the password never appears in the process list. One caveat we will not hide. MySQL commits most schema changes immediately, so a migration that fails halfway can leave the schema partly changed with no automatic undo. PostgreSQL wraps schema changes in transactions and handles this better. On MySQL, keep each migration small, so a failure leaves less to untangle.
Application cache and service restarts
After the symlink flips, PHP may still hold old files in OPcache, so the script reloads PHP-FPM. A reload finishes in flight requests before swapping workers, which avoids dropped connections. Queue workers and long running processes need their own restart, because they loaded the old code into memory and will keep running it until told otherwise.
Clear whatever your framework caches, such as compiled views, route caches and configuration caches, as part of the release, not by hand afterward. If a CDN sits in front of the site, purge it too. When you front a site with CloudFlare CDN, a stale cached page after a deploy is usually the cause of “I deployed but nothing changed.” Purge only what changed if you can, and everything if you cannot.
Post-deployment health checks
A good health endpoint does real work. It connects to the database, reads one row and confirms it can write to the cache or session store. A page that returns 200 with a blank body proves very little. The script requests it with curl -f, so any 4xx or 5xx status counts as failure.
If the check fails, the script flips the symlink back to the previous release and reloads PHP. That automatic rollback covers the most common failure, a release that boots badly. It does not cover a release that boots fine and corrupts data slowly, so keep watching the logs for a few minutes after every production deploy.
How Can You Roll Back a Failed Git Deployment on a VPS?
Point the current symlink back at the previous release folder and reload PHP, which takes seconds and needs no Git commands at all. Then restore the database from the backup taken before the deploy only if the migration cannot be tolerated by the old code. Verify the app, and mark the bad commit as unfit to ship.
Identifying the failed release
Every release folder is named with a timestamp and the short commit hash, such as 20261001143055_a1b2c3d. Run readlink -f /srv/app/production/current to see which one is live. Match that hash against the deploy log and you know exactly which commit is running and when it went out.
Then read the evidence in order: the deploy log, the web server error log, the PHP-FPM log and the application log. Decide quickly whether this is a bad release or a bad dependency such as a full disk or a down database service. Rolling back code does nothing for a problem that was never in the code.
Reverting to a known-good Git commit
You do not need Git to restore service, because the previous release folder already holds the known good files. Git matters for the record. The correct fix in history is git revert, which adds a new commit that undoes the bad one and keeps everything else intact.
Avoid git reset –hard followed by a force push on main. It rewrites shared history, confuses everyone else’s clones and is blocked on the server by the settings we applied earlier. Notice also that a revert commit has a new hash, so it must pass through staging like any other change. In a real emergency, use the symlink rollback to restore service first and send the revert through the normal gate afterward.
Restoring database changes
If the old code runs fine against the new schema, leave the database alone. If it does not, restore from the compressed backup the deploy script made. Put the site in maintenance mode first, so nobody writes data while you restore.
gunzip < /srv/app/backups/appprod_a1b2c3d.sql.gz \
| mysql –defaults-file=/srv/app/production/shared/backup.cnf appprod
Be clear about the cost. A restore returns the database to the moment of the backup, so every order, comment or signup made since the deploy is lost. For a busy shop that can be minutes of real orders. That is the strongest argument for backward compatible migrations. The best database rollback is the one you never have to run.
Using release directories and symlinks
The rollback script below finds the newest release that is not the current one and flips the symlink to it. Because the timestamp is in the folder name, sorting the names in reverse gives you newest first. It then reloads PHP and writes a line to the log.
#!/usr/bin/env bash
# /srv/app/bin/rollback.sh <staging|production>
set -euo pipefail
ENV=”$1″
BASE=”/srv/app/$ENV”
CURRENT=”$(readlink -f “$BASE/current”)”
PREVIOUS=”$(ls -1d “$BASE”/releases/* | sort -r | grep -vxF “$CURRENT” | head -n 1)” || true
[ -n “$PREVIOUS” ] || { echo “No earlier release found.”; exit 1; }
ln -sfn “$PREVIOUS” “$BASE/current.new”
mv -T “$BASE/current.new” “$BASE/current”
sudo /usr/bin/systemctl reload php8.3-fpm
echo “[$(date -Is)] $ENV rolled back from $CURRENT to $PREVIOUS” >> /srv/app/logs/deploy.log
Releases are full copies of the code plus installed dependencies, so keeping five costs real disk space. Storage speed shapes how quickly builds and copies finish, which is part of why we explain the difference between NVMe drives and SSDs. Whatever disk you use, the retention number of five is a trade. More releases give you more rollback depth and less free space.
Verifying the application after rollback
Run the same health check that approved the deploy. Then repeat your manual checks on the pages the failed release touched. Check that queue workers restarted on the old code, that scheduled jobs are running and that the error log has gone quiet.
Finally, take the bad commit off the approved list by deleting its line from staging_ok, so a stray push cannot ship it again before someone fixes it. A rollback is not finished when the site loads. It is finished when you are sure the site is behaving, and you have made it hard to repeat the mistake.
Keeping deployment logs for troubleshooting
Your deploy log is your memory when an incident drags on. A useful line says when it happened, which environment, which commit and who or what triggered it. Rollbacks write to the same file, so one chronological record shows deploys and rollbacks side by side.
Keep the logs off the critical path. Copy them, together with the database backups, to a different machine or storage bucket on a schedule. A log that lives only on the VPS disappears in exactly the disaster you needed it for. A short postmortem note beside the log, written the same day, is worth more than a perfect one written next week.
What Security Problems Should You Watch for With Git Hooks on a VPS?
The main danger is that anyone who can push to the repository can cause code to run on your server as the deploy user. Restrict SSH keys, protect the hook files, keep secrets out of Git and logs, limit what the deploy user can do, control who can push to which branch, and accept that one server is one point of failure.
SSH key restrictions
Give every developer their own SSH key, never a shared one, and disable password logins in sshd_config. Then lock each key to Git alone. The restrict option removes port forwarding and terminal access, and the forced command runs git-shell instead of a normal login shell.
restrict,command=”/usr/bin/git-shell -c \”$SSH_ORIGINAL_COMMAND\”” ssh-ed25519 AAAAC3Nza… alice@laptop
Test it before you rely on it. Try to open a normal session with the restricted key and confirm it is refused, then confirm a push still works. A restriction you never tested is a hope, not a control. With this in place, a stolen laptop key can push code but cannot open a shell. It is not harmless, since a push still triggers a deploy, but the damage is narrower. Remove the key of anyone who leaves the same day.
Git hook execution permissions
Hooks are code that runs automatically, so the hooks folder is a high value target. Anyone who can write to it can run commands as the deploy user. The repository we created is mode 750 and owned by deploy, so only that user can edit hooks.
Remember that your build step is also hook driven code. composer install can run scripts defined in the repository, and those scripts run as deploy. That means push access to main is, in effect, remote code execution on the server. Treat the people with that access accordingly. If you do not need Composer scripts in production, add –no-scripts to the install.
Environment variable and secret protection
Secrets belong in each environment’s .env file with mode 640, owned by deploy and readable by that environment’s group only. They never belong in Git, in git archive output or in log files. If a secret does get committed, treat it as published. Rotate it at once, because deleting the file in a later commit leaves it in history.
Search your history for obvious mistakes before you start. A simple git log -p scan for passwords or API keys takes minutes. Keep staging and production secrets different in every case, so that a leak from the weaker environment never opens the stronger one.
Git repository access control
The bare repository on the VPS is a delivery mechanism, not your source of truth. The simplest strong setup keeps GitHub or another host as the home of your code, with branch protection and reviews, and lets one trusted account or CI job push to the VPS. Then the question of who can ship to main is answered in one place.
Agencies managing many client servers feel this most. If you resell servers as part of your business, such as through our VPS and dedicated server reseller option, one deploy key per client repository keeps a mistake on one project from touching another. Never reuse a key across clients.
Deployment user privileges
The deploy user must not be root and must not have general sudo rights. Give it one narrow permission: reloading PHP-FPM. Do this with a file in /etc/sudoers.d edited through visudo, so a typo cannot lock you out.
# sudo visudo -f /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm
Check the rule works by running sudo -l as the deploy user and reading the list. It should show that one command and nothing else. Do not add the deploy user to the docker group if you use containers elsewhere on the machine. Membership in that group is effectively root access. Also keep the Nginx configuration out of the deploy user’s reach. A deploy that can rewrite your web server settings can also rewrite what your visitors see.
Single-server failure limitations
All of this makes a single VPS safer, not redundant. Disk failure, a kernel panic, a mistaken rm or a provider outage takes down staging and production together. Backups on the same machine protect against bad deploys, not against losing the machine. Copy backups and logs off the server on a schedule, and test restoring them.
When production begins to matter more than the saving, split the environments. A second small VPS for staging removes the shared resource risk. A dedicated server gives production its own hardware, and multi location hosting spreads risk across sites. Our support team is available 24/7 if you want a second opinion on the point where your own setup should grow.
Ready to put this pipeline on a real server? Start with a USA VPS from SkyNetHosting, then follow the steps above in a test project before you point a live site at it.