To use GitHub Actions to deploy to a server, you build and test in one job, then a second job connects over SSH with a deploy key stored as an environment secret, uploads the build into a new timestamped release directory, and switches a current symlink to it. The switch is atomic, so there is no half-deployed state, and rollback is just pointing the symlink back. Below is the complete workflow I use as a starting point, plus environments with approvals, a rollback job and OIDC for AWS so you never store long-lived AWS keys in GitHub.
The example deploys a Node.js app to an Ubuntu server running it under systemd behind Nginx, but the pattern is the same for PHP, Python or a static site: only the build and restart commands change.
Prepare the server and the deploy key
Create a dedicated user on the server for deployments. It should own the app directory and be able to restart the app, and nothing else:
sudo adduser --disabled-password --gecos "" deployer
sudo mkdir -p /var/www/myapp/{releases,shared}
sudo chown -R deployer:deployer /var/www/myapp
echo 'deployer ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp' | sudo tee /etc/sudoers.d/deployer
sudo chmod 440 /etc/sudoers.d/deployerGenerate a key pair used only for this pipeline, on your own machine, and install the public half for deployer:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
ssh-copy-id -i deploy_key.pub deployer@your-server
ssh-keyscan -t ed25519 your-server > known_hostsCheck the fingerprint in known_hosts against the server (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) before you trust it. Pinning the host key is what stops a pipeline from happily deploying to an impostor; running ssh-keyscan inside the workflow, which many tutorials do, skips that check. Server-side SSH settings for this user should follow the same rules as the rest of the box — see my Linux server hardening checklist.
Secrets, variables and environments with approvals
In the repository, go to Settings → Environments and create production. Store deploy credentials on the environment, not the repository, so only jobs that declare environment: production can read them:
| Name | Type | Value |
|---|---|---|
SSH_PRIVATE_KEY | Secret | Contents of deploy_key |
SSH_KNOWN_HOSTS | Secret | Contents of known_hosts |
DEPLOY_HOST | Variable | 203.0.113.20 or hostname |
DEPLOY_USER | Variable | deployer |
On the same page, add protection rules: required reviewers (the deploy job pauses until one of them clicks Approve), a wait timer if you want a cooling-off period, and deployment branches limited to main so a feature branch cannot deploy to production. At the time of writing, environments are available on all plans for public repositories, but required reviewers on private repositories need GitHub Enterprise; on other plans you can still restrict deployment branches and rely on branch protection for review.
Create a staging environment the same way with its own secrets, and you get a clean staging-then-production flow with the same workflow file.
The GitHub Actions SSH deploy workflow
Save this as .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- run: tar -czf release.tar.gz dist package.json package-lock.json
- uses: actions/upload-artifact@v4
with:
name: release
path: release.tar.gz
retention-days: 7
deploy:
needs: build
runs-on: ubuntu-24.04
environment:
name: production
url: https://example.com
steps:
- uses: actions/download-artifact@v4
with:
name: release
- name: Configure SSH
run: |
install -d -m 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Upload and activate release
env:
TARGET: ${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }}
RELEASE: ${{ github.sha }}
run: |
scp release.tar.gz "$TARGET:/tmp/release-$RELEASE.tar.gz"
ssh "$TARGET" "RELEASE=$RELEASE bash -s" <<'EOF'
set -euo pipefail
APP=/var/www/myapp
DIR="$APP/releases/$(date +%Y%m%d%H%M%S)-${RELEASE:0:7}"
mkdir -p "$DIR"
tar -xzf "/tmp/release-$RELEASE.tar.gz" -C "$DIR"
rm "/tmp/release-$RELEASE.tar.gz"
ln -sfn "$APP/shared/.env" "$DIR/.env"
cd "$DIR" && npm ci --omit=dev --no-audit --no-fund
ln -sfn "$DIR" "$APP/current.new"
mv -Tf "$APP/current.new" "$APP/current"
sudo /usr/bin/systemctl restart myapp
curl -fsS --retry 10 --retry-delay 2 --retry-all-errors http://127.0.0.1:3000/health
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
EOFA few choices in there are deliberate:
concurrencywithcancel-in-progress: falsequeues deploys instead of running two at once or killing one halfway through.- Build once, deploy the artifact. The server never runs the build or needs your dev dependencies, and what you tested is exactly what ships.
set -euo pipefailmeans a failed extract or install aborts before the symlink moves, so a broken build never goes live. If the health check fails after the switch, the job goes red and you run the rollback workflow below.- The last five releases are kept for rollback; older ones are deleted.
If the SSH step fails with Permission denied (publickey), the usual causes are a secret pasted without its final newline or header lines, the public key installed for the wrong user, or wrong permissions on ~/.ssh on the server. Host key verification failed means SSH_KNOWN_HOSTS does not match the server.
Zero-downtime deploys with symlink releases
The server layout after a few deploys looks like this:
/var/www/myapp/
├── current -> releases/20260921143012-3f9c2ab
├── releases/
│ ├── 20260920101544-a1b2c3d
│ └── 20260921143012-3f9c2ab
└── shared/
├── .env
└── uploads/Your systemd unit and Nginx config point at /var/www/myapp/current, never at a specific release. Anything that must survive deploys — .env, user uploads, SQLite files — lives in shared/ and is symlinked into each release.
Why ln -sfn followed by mv -Tf rather than just ln -sfn on current? Because ln -sfn removes the old link and creates a new one, leaving a tiny window where current does not exist. mv -T uses a single rename() system call, which is atomic: every request sees either the old release or the new one.
For truly zero downtime, the restart must also be graceful. PHP-FPM handles this with systemctl reload php8.3-fpm. For Node.js, a plain restart drops in-flight connections for a second or two; run two instances behind Nginx and restart them one at a time, or use a process manager with a zero-downtime reload such as pm2 reload. Database migrations need to be backward compatible (add columns first, remove them in a later release), because for a moment old and new code may both be running.
Rollback in one click
Because the previous release is still on disk, rollback is a symlink change. Add this as a separate workflow, .github/workflows/rollback.yml, triggered manually from the Actions tab:
name: Rollback
on:
workflow_dispatch:
jobs:
rollback:
runs-on: ubuntu-24.04
environment: production
steps:
- name: Point current at the previous release
run: |
install -d -m 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
ssh ${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }} '
set -euo pipefail
APP=/var/www/myapp
PREV=$(ls -1dt "$APP"/releases/* | grep -v "$(readlink -f "$APP/current")" | head -n 1)
ln -sfn "$PREV" "$APP/current.new" && mv -Tf "$APP/current.new" "$APP/current"
sudo /usr/bin/systemctl restart myapp
echo "Rolled back to $PREV"'Rolling back code does not roll back the database, which is another reason to keep migrations additive. I keep this workflow even on projects that rarely use it; the one time you need it, you will not want to be typing symlink commands under pressure.
Variation: a CI/CD pipeline with GitHub Actions and Docker
If the app runs in containers, the shape stays the same but the artifact is an image. The build job pushes to GitHub Container Registry with docker/build-push-action, tagging the image with the commit SHA, and the deploy job runs this over SSH:
cd /opt/myapp
export IMAGE_TAG=3f9c2ab
docker compose pull web
docker compose up -d --wait web
docker image prune -fRollback becomes redeploying the previous tag. Tagging by SHA rather than latest is what makes that possible. The server-side setup — healthchecks, restart policies, log limits — is covered in Docker Compose in production, and it is the kind of setup I do as part of Docker containerization work.
OIDC for AWS: no stored access keys
If the pipeline touches AWS (pushing to ECR, syncing to S3, invalidating CloudFront, running SSM commands), do not store an IAM user’s access keys as secrets. GitHub can issue a short-lived OIDC token that AWS exchanges for temporary credentials. Create the identity provider token.actions.githubusercontent.com in IAM with audience sts.amazonaws.com, then a role whose trust policy is locked to your repository and environment:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/webapp:environment:production"
}
}
}In the workflow, grant the job permission to request the token and assume the role:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy-production
aws-region: us-east-1If you forget id-token: write, the action fails with Credentials could not be loaded. If the sub condition does not match (a common one is using ref:refs/heads/main in the policy while the job declares an environment), you get Not authorized to perform sts:AssumeRoleWithWebIdentity. The sub claim uses the environment when one is set.
When it is worth getting help
A single app on a single server is a good afternoon project with the workflow above. It is worth getting help when you have several services, multiple environments, database migrations that need ordering, or deploys that currently involve someone SSHing in and running commands by hand. My CI/CD with GitHub Actions service sets up pipelines like this one, with approvals, rollback and OIDC, and documents them for your team. Small jobs are $20/hour; a full pipeline build is a fixed price agreed in writing. If you already have a pipeline that is flaky or slow, I can also review and fix your existing GitHub Actions workflows.
Frequently asked questions
Should I use appleboy/ssh-action or plain ssh commands?
Either works. Plain ssh and scp on the runner have no third-party dependency and are easy to debug. If you use a marketplace action, pin it to a full commit SHA rather than a tag, because the action runs with access to your deploy key.
Can GitHub Actions deploy to a server behind a firewall?
GitHub-hosted runners use a large, changing range of IP addresses, so allowlisting them is impractical. Options are a self-hosted runner inside your network, a VPN or Tailscale step in the workflow, or AWS Systems Manager Run Command authenticated through OIDC instead of SSH.
How do I run database migrations in a GitHub Actions deploy?
Run them on the server from the new release directory after dependencies are installed and before the symlink switch, and make them backward compatible so old code keeps working if the deploy is rolled back. Take a backup before destructive migrations.
Is it safe to store an SSH private key in GitHub secrets?
It is reasonable if the key is dedicated to deployment, the secret is scoped to a protected environment, and the server user it maps to has minimal permissions. Rotate it if anyone with admin access to the repository leaves.