GitHub Actions to a VPS: Deploys With Zero Downtime
Deploy Docker apps from GitHub Actions to an Ubuntu VPS with blue-green containers, health checks, restricted SSH access, graceful switching, and rollback.

On this page
- 1. Make your application safe to run twice
- 2. Prepare the server and runtime secrets
- 3. Put Nginx in front of two private container slots
- 4. Install the server-side release transaction
- 5. Give GitHub a restricted deployment identity
- 6. Build once and deploy the resulting digest
- 7. Exercise rollback before depending on it
- 8. Prove the handover with a staging release
- FAQ
You will have a GitHub Actions pipeline that builds a Docker image, starts it beside your running application, checks readiness, and switches Nginx without stopping active requests. Keep the previous container available for rollback and drain connections before reusing its slot. This provides zero-downtime application releases on a single VPS when both releases can run together; it does not protect against server failure.
1. Make your application safe to run twice
Use an Ubuntu 24.04 LTS VPS with enough spare memory to run the current application and its replacement together. This procedure assumes an existing Dockerized HTTP application, a GitHub repository, and a domain pointing to your server. Use an administrator SSH account for initial setup; GitHub will receive a separate deployment identity.
We recommend blue-green deployment for a small application that needs uninterrupted releases: Nginx sends new requests to either a blue container or a green container. Both use the same external database, and neither stores durable data inside its container. If this is your first deployment, start with the VPS deployment ladder before adding automation.
- Make the application listen on
0.0.0.0:3000inside its container. Provide/readyz, returning HTTP 200 only when startup is complete and required dependencies are usable. - Provide Dockerfile stages named
testandruntime. Buildingtestmust execute your actual application tests and fail when they fail;runtimemust contain the production application. - Keep sessions and uploads outside the container. Configure a shared session store or compatible signed cookies so a proxy switch does not log users out.
- Disable embedded schedulers and queue consumers in these web containers. Run those separately unless their work is explicitly safe to execute concurrently.
- Use backward-compatible database changes. Add new structures first, deploy compatible code, and remove old structures in a later release after the rollback window closes.
The configuration below targets ordinary HTTP requests, including requests that take time to finish. WebSockets require additional proxy configuration and a deliberate reconnect policy before adopting this procedure. A database migration that locks a busy table can still interrupt service, even when the container switch works correctly.
2. Prepare the server and runtime secrets
Use this installation block on a fresh Ubuntu 24.04 LTS server. If Docker or Nginx already exists, inspect that installation before changing packages or configuration. Keep your administrator SSH session open throughout setup.
set -eu
sudo apt-get update
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io nginx curl certbot python3-certbot-nginx
if ! id deploy >/dev/null 2>&1; then
sudo useradd --create-home --shell /bin/bash deploy
fi
sudo install -d -o root -g root -m 0755 /home/deploy /home/deploy/.ssh
sudo install -d -o root -g root -m 0700 /etc/web-release
sudo install -d -o root -g root -m 0755 /etc/nginx/web-slots
sudo touch /etc/web-release/app.env
sudo chmod 0600 /etc/web-release/app.envid deploy shows a deployment account without membership in the Docker group.Put your application's production environment variables in /etc/web-release/app.env, using Docker's environment-file syntax. Keep the file owned by root and readable only by root. Do not bake these values into the image or upload the file as a workflow artifact; the production Docker Compose checklist covers the surrounding storage and runtime decisions.
The examples use ghcr.io/example-team/web as the image repository and app.example.com as the application domain. Replace both consistently before installing the configuration. This workflow uses a hosted Linux runner, so choose a VPS with the matching processor architecture or explicitly adapt the build for your server.
For a private GitHub Container Registry package, authenticate root's Docker client on the VPS with a dedicated identity that can read that package. Use a token with read:packages, authorize it for your organization if required, and pass it through docker login ghcr.io --username ACCOUNT --password-stdin. The server needs package-read access; the workflow receives separate package-write permission.
3. Put Nginx in front of two private container slots
Blue will bind to 127.0.0.1:18081, and green will bind to 127.0.0.1:18082. Only Nginx should accept public application traffic. Allow HTTP and HTTPS through your server and provider firewalls while preserving SSH access.
Save these files under /etc/nginx/web-slots/, owned by root. The bootstrap configuration intentionally returns an unavailable response until the first application starts; zero downtime applies to subsequent releases, not initial provisioning.
# /etc/nginx/web-slots/bootstrap.conf
return 503;# /etc/nginx/web-slots/blue.conf
proxy_pass http://127.0.0.1:18081;
proxy_hide_header X-Deploy-Slot;
add_header X-Deploy-Slot blue always;# /etc/nginx/web-slots/green.conf
proxy_pass http://127.0.0.1:18082;
proxy_hide_header X-Deploy-Slot;
add_header X-Deploy-Slot green always;Save the following as /etc/nginx/sites-available/web, substituting your domain. The deployment script changes only the included slot file, so certificate management can maintain the surrounding server configuration.
server {
listen 80;
server_name app.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 300s;
include /etc/nginx/web-slots/active.conf;
}
}After creating the files, activate the bootstrap slot and obtain your certificate. Replace the example domain and email before running this block, and confirm DNS points to this VPS. Run these bootstrap commands only during initial setup.
set -eu
sudo systemctl enable --now docker nginx
sudo ln -s /etc/nginx/web-slots/bootstrap.conf /etc/nginx/web-slots/active.conf
sudo ln -s /etc/nginx/sites-available/web /etc/nginx/sites-enabled/web
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx --non-interactive --agree-tos --email [email protected] -d app.example.com --redirectA graceful reload starts new Nginx workers while older workers finish serving their existing clients. That behavior only preserves application requests if the old upstream container stays available. The Nginx process-control documentation explains this handover.
4. Install the server-side release transaction
Save this script as /usr/local/sbin/web-release, owned by root with mode 0755. Replace the repository and domain at the top. The script accepts an immutable image digest or the word rollback; it cannot accept an arbitrary repository or shell command.
A server-side lock serializes releases even when someone deploys outside GitHub. Before replacing an inactive container, the script refuses to proceed while old Nginx workers are draining. It deliberately leaves both containers running after a successful switch.
#!/bin/bash
set -Eeuo pipefail
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
umask 077
repository=ghcr.io/example-team/web
domain=app.example.com
slots=/etc/nginx/web-slots
request=${1:-}
[[ $# == 1 ]] || exit 64
if [[ "$request" != rollback && ! "$request" =~ ^deploy\ sha256:[a-f0-9]{64}$ ]]; then
echo 'Expected deploy followed by a digest, or rollback' >&2
exit 64
fi
exec 9>/run/lock/web-release.lock
flock -n 9 || { echo 'Another release is running' >&2; exit 75; }
old=$(readlink "$slots/active.conf")
case "$old" in
"$slots/blue.conf") next=green; port=18082 ;;
"$slots/green.conf") next=blue; port=18081 ;;
"$slots/bootstrap.conf") next=blue; port=18081 ;;
*) echo 'Unexpected active configuration' >&2; exit 1 ;;
esac
switch_to() {
ln -sfn "$1" "$slots/active.next"
mv -Tf "$slots/active.next" "$slots/active.conf"
}
changed=0
finish() {
rc=$?
trap - EXIT HUP INT TERM
if (( changed )); then
echo 'Restoring the previous proxy configuration' >&2
if switch_to "$old" && nginx -t && systemctl reload nginx; then
echo 'Previous configuration restored; verify public traffic' >&2
else
echo 'RESTORE FAILED: administrator intervention required' >&2
fi
rc=1
fi
exit "$rc"
}
trap finish EXIT
trap 'exit 1' HUP INT TERM
ready() {
local attempt code
for attempt in $(seq 1 60); do
code=$(curl --silent --output /dev/null --write-out '%{http_code}' \
--connect-timeout 2 --max-time 3 "http://127.0.0.1:$port/readyz") || code=000
[[ "$code" == 200 ]] && return 0
sleep 2
done
return 1
}
proxy_ready() {
local attempt response
for attempt in $(seq 1 30); do
response=$(curl --silent --fail --max-time 5 \
--resolve "$domain:443:127.0.0.1" \
--dump-header - --output /dev/null \
"https://$domain/readyz") || response=''
if printf '%s\n' "$response" | tr -d '\r' | \
grep -Fqx "X-Deploy-Slot: $next"; then
return 0
fi
sleep 1
done
return 1
}
if [[ "$request" == rollback ]]; then
[[ "$old" != "$slots/bootstrap.conf" ]] || exit 1
[[ $(docker inspect --format '{{.State.Running}}' "web-$next") == true ]]
else
if pgrep -f '^nginx: worker process is shutting down' >/dev/null; then
echo 'Old Nginx workers are still draining; retry later' >&2
exit 75
fi
image="$repository@${request#deploy }"
docker pull "$image"
if docker container inspect "web-$next" >/dev/null 2>&1; then
docker rm -f "web-$next"
fi
docker run --detach --name "web-$next" \
--restart unless-stopped \
--publish "127.0.0.1:$port:3000" \
--env-file /etc/web-release/app.env \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--log-driver local \
"$image"
fi
ready || { echo 'Candidate readiness failed; traffic unchanged' >&2; exit 1; }
changed=1
switch_to "$slots/$next.conf"
nginx -t
systemctl reload nginx
proxy_ready || { echo 'Proxy verification failed' >&2; exit 1; }
changed=0
printf 'Active slot: %s\n' "$next"
docker inspect --format '{{.Config.Image}}' "web-$next"The direct readiness check prevents a known-bad candidate from receiving traffic. The second check goes through local HTTPS and verifies the slot header, catching a reload that did not activate the intended configuration. A caught failure after switching restores the previous proxy configuration; a server crash or forced process kill still requires operator inspection.
Keep Nginx configuration changes under the same operational lock, and do not set a worker shutdown timeout that forcibly cuts off requests you intend to preserve. Docker supports pulling an image by digest, so the server can run the exact artifact selected by the workflow. Retain those artifacts in the registry through your rollback window.
sudo bash -n /usr/local/sbin/web-release to check syntax. An invalid request such as sudo /usr/local/sbin/web-release invalid must exit with an error before pulling an image or changing Nginx.5. Give GitHub a restricted deployment identity
Generate a dedicated SSH key on your laptop and store its private half in the GitHub production environment. The deployment key should invoke only the release script. Do not add deploy to the Docker group, because access to the Docker socket would bypass this restriction.
ssh-keygen -t ed25519 -N '' -f ./web-actions -C web-actionsSave this wrapper as /usr/local/bin/web-entry, owned by root with mode 0755. The entire original SSH command becomes one argument, and the root script validates it before acting.
#!/bin/bash
set -eu
exec /usr/bin/sudo -n /usr/local/sbin/web-release "${SSH_ORIGINAL_COMMAND:-}"Create /etc/sudoers.d/web-release with the following rule, root ownership, and mode 0440. Validate it with sudo visudo -cf /etc/sudoers.d/web-release before attempting a deployment.
deploy ALL=(root) NOPASSWD: /usr/local/sbin/web-releaseCreate /home/deploy/.ssh/authorized_keys by prefixing the complete contents of web-actions.pub with restrict,command="/usr/local/bin/web-entry" followed by a space. Keep that file root-owned with mode 0600; the home and .ssh directories prepared earlier must also remain root-owned. Use this identity only for deployment and leave password authentication unavailable for it.
Obtain the server's SSH host public key through your existing trusted administrator connection or provider console. Create a known-hosts entry containing the real server IP, key type, and public key. Do not populate it from an unverified network scan during the workflow.
| Production environment setting | Value |
|---|---|
| Secret: DEPLOY_SSH_KEY | Complete private key from web-actions |
| Secret: DEPLOY_KNOWN_HOSTS | Verified known-hosts entry for your server |
| Variable: VPS_HOST | Your real server IPv4 address |
| Variable: APP_URL | Your public HTTPS origin, without a trailing slash |
Restrict the production environment to your protected main branch, and enable required reviewers where your repository supports them. GitHub's deployment environment documentation describes these controls and when environment secrets become available. Follow the same separation described in secrets and scoped tokens for agents.
id. The server must reject it as an invalid deployment request, rather than print user information or open a shell.6. Build once and deploy the resulting digest
Save this workflow as .github/workflows/deploy.yml and replace the image repository. Ensure the repository's workflow token can publish that package; existing packages may require explicit Actions access. The checkout action is pinned to a commit, and you should review action-pin updates through ordinary pull requests.
Tests run before the production image is published. Workflow concurrency prevents overlapping runs, while cancel-in-progress: false avoids interrupting a release halfway through its switch. The server lock remains necessary because workflow concurrency does not cover administrator commands.
name: Deploy web
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: production-web
cancel-in-progress: false
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
env:
IMAGE: ghcr.io/example-team/web
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Run application tests
run: docker build --no-cache --target test .
- name: Build and publish
id: image
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
printf '%s' "$GHCR_TOKEN" | docker login ghcr.io \
--username "$GITHUB_ACTOR" --password-stdin
trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT
tag="$IMAGE:$GITHUB_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
docker build --pull --target runtime --tag "$tag" .
docker push "$tag"
docker pull "$tag"
reference=$(docker image inspect --format '{{index .RepoDigests 0}}' "$tag")
digest=${reference##*@}
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
- name: Deploy and verify public readiness
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
VPS_HOST: ${{ vars.VPS_HOST }}
APP_URL: ${{ vars.APP_URL }}
DIGEST: ${{ steps.image.outputs.digest }}
run: |
set -euo pipefail
[[ "$VPS_HOST" =~ ^[0-9.]+$ ]]
[[ "$APP_URL" == https://* ]]
[[ "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
install -d -m 0700 "$HOME/.ssh"
umask 077
printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/web-actions"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
trap 'rm -f "$HOME/.ssh/web-actions"' EXIT
ssh -T -i "$HOME/.ssh/web-actions" \
-o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=yes -o ConnectTimeout=10 \
"deploy@$VPS_HOST" "deploy $DIGEST"
status=$(curl --silent --show-error --output /dev/null \
--write-out '%{http_code}' --connect-timeout 5 --max-time 15 \
"$APP_URL/readyz")
[[ "$status" == 200 ]]/readyz endpoint returns HTTP 200, and the response includes the corresponding X-Deploy-Slot header.A failed external check marks the workflow failed but does not automatically roll back: the fault might be DNS or the external network rather than the application. Inspect the public response and the active container before deciding. Ensure any CDN bypasses caching for /readyz, or it can conceal a failed origin.
7. Exercise rollback before depending on it
After two successful releases, both slots should contain working application releases. Run rollback from your laptop using the same restricted key and a verified known-hosts entry. Replace the documentation IP below with your actual server address.
ssh -T -i ./web-actions -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes [email protected] rollbackRollback switches to the retained container after checking its readiness; it does not rebuild anything or reverse database changes. A repeated rollback switches back again, so inspect the current release before issuing the command. The first deployment has no previous application container to restore.
Starting another deployment reuses the inactive slot once Nginx has drained, ending that container's immediate rollback window. Keep earlier image digests available if you need to redeploy an older artifact. Avoid automatic image pruning until your retention policy accounts for those releases.
If Claude Code helps operate this setup, give it permission to inspect logs and propose a rollback, with production mutation limited to the reviewed release command. Keep SSH administration and secret rotation outside that routine scope. The production agent guardrails explain how to separate investigation from changes.
8. Prove the handover with a staging release
Make your next task a staging deployment while an external client repeatedly requests a representative application route. Record failed requests and latency, and hold one deliberately slow request open across the switch. Both quick requests and the existing slow request should finish successfully.
- Publish a candidate whose readiness endpoint fails. Confirm the workflow fails while the previously active application continues serving traffic.
- Publish a healthy candidate while the slow request remains open. Confirm new requests reach the new slot and the slow request completes through the old container.
- Attempt another release before the old workers finish draining. Confirm the server refuses to reuse the slot, then retry after they exit.
- Roll back and verify an authenticated application operation. Confirm the database remains compatible with the retained release.
Add an external availability check and an alert for application errors before treating a green workflow as sufficient operational feedback. A readiness endpoint can remain healthy while a business operation fails. Keep the VPS itself on a separate maintenance plan, because rebooting this single host still interrupts the application.
FAQ
How do I deploy to a VPS with GitHub Actions?
Have GitHub Actions build and verify a release artifact, then use a dedicated SSH identity to ask the VPS to activate it. Keep production environment variables on the server and verify its SSH host key before connecting. A restricted server-side deployment command gives you a smaller permission boundary than allowing the workflow to execute arbitrary administrator commands.
Can GitHub Actions deploy Docker containers to a VPS?
Yes. Publish the application image to a registry that the VPS can read, then send its digest to a server-side deployment script. The server pulls and runs that artifact without needing your source repository or build tools. Match the image's processor architecture to the VPS, and keep registry credentials separate from the application's runtime secrets.
Does Docker Compose provide zero-downtime deployments?
Replacing the only container serving an application can leave a gap while its replacement starts. Compose is useful for defining services, but uninterrupted deployment also needs spare application capacity and controlled traffic switching. You can implement separate blue and green Compose projects, provided their ports and container identities differ and shared state remains compatible.
Can a single VPS really have zero-downtime deployments?
It can avoid interruption during a planned application release when the old and new processes overlap and existing connections finish before cleanup. That guarantee has a narrow scope. A host reboot, exhausted memory, or a database outage can still stop both releases. Higher availability requires removing those shared failure points, usually with additional infrastructure.
How do I roll back a failed GitHub Actions deployment?
Retain the previous working container and switch the proxy back after checking that container's readiness. Preserve the corresponding image digest so you can recreate it later if necessary. Database changes need separate planning: reverting application code cannot recover deleted columns or undo incompatible data transformations. Establish the rollback boundary before accepting the release.
Put this guide in the bigger picture
See how this topic connects to the rest of your product. Our free field guide explains apps, infrastructure and AI, with checklists to help you make business decisions.






