GitHub Actions to a VPS: A Pipeline That Doesn't Need a Cloud Platform
You do not need a cloud platform for push-to-deploy. You need a runner that can build, a server that can receive, and a key that can do exactly one thing — and most of the work is making sure that key cannot do anything else.
You do not need a cloud platform to get push-to-deploy. You need a runner that can build, a server that can receive, and a key that can do exactly one thing. The whole pipeline is about sixty lines of YAML, and most of the work is in making sure that key cannot do anything else.
This is the pipeline I run on every VPS project — build, test, package, ship, swap, verify — and the security decisions inside it, which are the part worth arguing about.
The server side
A deploy user that can deploy and nothing else
The default advice is "add your SSH key and run a script". That gives your CI runner a shell on your production server, which means a compromised Action, a malicious dependency in your build, or a leaked secret is full server access. It is worth twenty minutes to make that key much less interesting to steal.
-
A dedicated user that owns only the app
deploy, no password, home directory outside the web root. It owns/var/www/appand nothing else on the box. -
A key restricted to one command
The
command=option inauthorized_keysmeans the key can only ever run the deploy script, whatever the client asks for. This is the single highest-value line in the whole setup. -
Narrow sudo, spelled out
The deploy needs to reload PHP-FPM and restart the worker. That is two entries in a sudoers file with
NOPASSWD— not membership ofsudo. -
The deploy script validates its own input
It receives an uploaded tarball at a known path. It does not take arguments from the caller, because the caller is the thing you are defending against.
# This key can do exactly one thing. `ssh deploy@host anything-at-all` runs
# deploy.sh regardless — the requested command is ignored entirely.
command="/usr/local/bin/deploy.sh",no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAAC3Nza... github-actions@app
deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.2-fpm
deploy ALL=(root) NOPASSWD: /bin/systemctl restart app-worker
no-pty matters
Without it, a forced command still allows an interactive terminal allocation, and several escapes rely on that. With no-pty, no-port-forwarding and a forced command, a stolen deploy key buys an attacker one thing: the ability to trigger a deploy of code that is already in your repository. That is a genuinely boring prize, which is the goal.
The workflow
The pipeline, end to end
name: Deploy
on:
push:
branches: [main]
concurrency:
group: production
cancel-in-progress: false # never abandon a half-finished deploy
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # gives you required reviewers if you want them
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.2', extensions: mbstring, intl, pdo_mysql }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- name: Cache composer
uses: actions/cache@v4
with:
path: ~/.cache/composer
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-dev --optimize-autoloader --prefer-dist
- run: npm ci && npm run build
- run: php artisan test
- name: Package
run: |
tar -czf release.tar.gz \
--exclude='.git' --exclude='node_modules' --exclude='tests' \
--exclude='.env' --exclude='storage/app' \
.
- name: Load the deploy key
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Pin the host key. Without this you are trusting DNS.
echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Ship
run: scp -i ~/.ssh/id_ed25519 release.tar.gz deploy@${{ secrets.HOST }}:/tmp/release.tar.gz
- name: Deploy and verify
run: ssh -i ~/.ssh/id_ed25519 deploy@${{ secrets.HOST }}
# No command needed — the key is forced to deploy.sh, which swaps the
# symlink, reloads FPM, health-checks and rolls back on failure.
Details
Four things that are easy to get wrong
StrictHostKeyChecking=no
Every tutorial does this. It means your pipeline will happily hand its key to whatever answers on that IP, which is the entire attack DNS hijacking exists for.
Pin the host key in a secret.
ssh-keyscan your.host once, paste the output into KNOWN_HOSTS. It takes a minute and closes the hole permanently.
cancel-in-progress: false. The default cancels a running job when a new commit lands — mid-upload, mid-symlink-swap. Deploys are the one workflow you never want interrupted.- Cache
~/.cache/composer, notvendor/. Caching the output means a stalevendorcan survive a lock change; caching the download cache keepscomposer installauthoritative and still fast. - Exclude
.envfrom the tarball explicitly, even though it should not be in the repo. Defence in depth costs one line. - Use an
environment:. It scopes the secrets to production and gives you optional required reviewers, which turns a push into a deploy request when you want that.
Honesty
What this is not
- Not a build cache across runs beyond dependencies. Every deploy recompiles assets. On a large frontend that is minutes; a Turborepo or Vite cache keyed on source hash is the next step.
- Not multi-server. One host in a secret. Several means a matrix and a decision about whether a partial failure is a rollback everywhere.
- Not a canary. Everyone moves at once, so the health check is doing all the work. Make it hit a real endpoint that touches the database, not a static
ok. - Not zero-downtime by itself. That comes from the symlink layout on the server; the pipeline just delivers the tarball.
- Not free of GitHub. Fewer moving parts than a cloud platform, but if Actions is down you deploy by hand — worth having the manual path written down.
The checklist
- A dedicated
deployuser owning only the application directory. - A forced-command SSH key with
no-ptyand no forwarding. - Two explicit sudoers lines, never group membership.
- Pinned host key — never
StrictHostKeyChecking=no. - Tests before packaging, in the same job.
.envand uploads excluded from the artifact.- Concurrency group with
cancel-in-progress: false. - Cache the dependency cache, not the installed output.
- The server script health-checks and rolls back itself, so a red job means a live site.
- A written manual deploy path for when the pipeline is unavailable.
Where to start on Monday
Add command="/usr/local/bin/deploy.sh" to your existing deploy key. If you already have a pipeline, that one edit turns a shell credential into a single-purpose one, and it takes about a minute.
- forced command
- known_hosts pinning
- concurrency group
- artifact packaging
- narrow sudo
- composer cache
- health check
- auto rollback
The server-side script this triggers is the symlink swap, the box should be hardened before any of this, and if you are on shared hosting instead, the FTP and cron version is the equivalent.
Comments (0)
No comments yet
Be the first to share a thought on this article.
Join the conversation