Blog

From Git Push to Live Server Without Touching SSH

Build a GitHub Actions pipeline that tests, builds, and deploys your Node app to a VPS automatically so every merge ships itself safely.

February 23, 20266 min readMuhammad Shehzaib
DEVOPSCICDGITHUB

Manually deploying by SSHing into a server, pulling the latest code, and restarting the process is fine until the day you forget a step, deploy from the wrong branch, or push code that does not even pass tests. A continuous integration and delivery pipeline replaces that ritual with an automated process triggered by a git event. GitHub Actions runs in your repository, so you describe what should happen on every push or pull request in a YAML workflow file and GitHub provisions fresh machines to run it. For a backend developer the payoff is concrete: every change is automatically tested, the image is built once, and your VPS is updated the same way every time. In this article we build that pipeline step by step, from running tests to shipping a Docker image to your server, and we cover the safety nets that keep bad code from reaching production.

Workflows, Jobs, And Steps

A GitHub Actions workflow is a YAML file in the .github/workflows directory that defines automation triggered by events. The core hierarchy is simple: a workflow contains jobs, and each job contains steps. A job runs on a fresh virtual machine called a runner, and by default jobs run in parallel unless you declare dependencies between them with the needs keyword. Steps run sequentially within a job and are either shell commands you write or reusable actions published by others, referenced with the uses keyword. Because each job starts on a clean runner, anything one job produces does not automatically reach another; you share files between jobs using artifacts and share computed values using outputs. Grasping this structure early prevents the common confusion of expecting state to persist across jobs. Think of each job as an isolated container that you must explicitly feed inputs and from which you collect outputs.

Triggering On The Right Events

Workflows run in response to events declared under the on key, and choosing the right triggers shapes your whole pipeline. The common pattern is to run tests on pull requests targeting your main branch so problems are caught before merge, and to run the full build and deploy only on pushes to main so production updates exactly when code lands. You can scope triggers further with path filters so a workflow only runs when relevant files change, and branch filters so feature branches do not deploy. The workflow_dispatch event adds a manual button in the GitHub UI, which is invaluable for triggering a deploy or rollback on demand. Be careful with the pull_request_target event and forks, since misusing them can expose secrets to untrusted code. Designing triggers deliberately keeps continuous integration fast on every change while reserving deployment for the moments you actually intend to ship.

Caching Dependencies For Speed

A slow pipeline gets ignored, so caching is essential. Reinstalling node_modules from scratch on every run wastes minutes, and those minutes compound across a team. The setup-node action has built-in caching that keys on your lock file, restoring the npm cache when dependencies have not changed and saving a fresh one when they have. For more control you can use the cache action directly, specifying the paths to store and a key derived from a hash of your package-lock.json so the cache invalidates only when dependencies actually change. Always run npm ci rather than npm install in CI because it installs exactly what the lock file pins, is faster, and fails loudly on any mismatch between the manifest and the lock file. If you build Docker images, enable layer caching too, since rebuilding unchanged layers on every run defeats the purpose of the careful Dockerfile ordering you set up earlier.

Running Tests And Linting

The continuous integration half of the pipeline exists to give you confidence that a change is safe. A solid job checks out the code, sets up the right Node version, installs dependencies with npm ci, then runs your linter, type checker, and test suite as separate steps so a failure tells you exactly what broke. If your tests need a database or cache, GitHub Actions service containers let you spin up Postgres or Redis alongside the job, reachable on localhost, without managing them yourself. Set environment variables to point your tests at those services. Make the job fail fast and loudly: a non-zero exit from any step stops the workflow and blocks the merge if you have branch protection enabled. Keep this job fast, ideally under a few minutes, because developers wait for it on every pull request and a slow gate erodes the discipline it enforces.

Building And Pushing Images

Once tests pass on main, the delivery half builds your production Docker image and pushes it to a registry so your server can pull it. The GitHub Container Registry is convenient because it lives next to your code and authenticates with the built-in token, avoiding extra credentials. Use the official Docker build-push action, which handles building from your Dockerfile, logging into the registry, and pushing in one step, with support for layer caching to keep builds quick. Tag images meaningfully: tag with the git commit SHA so every build is uniquely identifiable and rollbacks are precise, and optionally tag latest for convenience. Building the image once in CI and shipping that exact artifact to the server is far safer than building on the VPS, because the thing you tested is the thing you run. This also keeps build tooling and load off your production machine entirely.

Deploying To Your VPS

With the image in a registry, deployment becomes telling your VPS to pull the new tag and restart. A straightforward approach uses an SSH action that connects to your server with a deploy key stored as a secret, then runs a short script: log in to the registry, pull the new image, and run docker compose up to recreate the changed service with the new tag. Pass the commit SHA as the image tag so you deploy precisely what was built. Keep the script idempotent and small, and have it prune old images occasionally so disk does not fill. A more advanced setup uses a self-hosted runner or a pull-based agent on the server, but for most single-VPS deployments SSH plus Compose is reliable and easy to reason about. Always restrict the deploy key to the minimum it needs and rotate it if it is ever exposed.

Secrets In The Pipeline

Pipelines need credentials: a deploy key, a registry token, sometimes API keys for smoke tests. Never hardcode these in the workflow file, which lives in your repository and is visible to anyone with read access. GitHub provides encrypted secrets at the repository, environment, and organization level, injected into the workflow as masked variables that are redacted from logs. Reference them through the secrets context and pass them to steps as environment variables only where needed. Use GitHub Environments to gate production secrets behind required reviewers, so deploying to production needs an approval and only then exposes the production credentials. Be aware that anything printed to the log could leak a secret if you echo it carelessly, and that pull requests from forks should not receive your secrets. Scope each secret to the narrowest level that works, and prefer short-lived tokens over static keys.

Safe Rollouts And Rollback

Automation that can break production fast is only good if it can recover fast too. Tagging every image with its commit SHA means rollback is just redeploying a previous known-good tag, so keep recent images available rather than overwriting a single tag. Add a health check step after deploy that polls your health endpoint and fails the job if the new version does not come up. Protect the main branch so code only merges after CI passes and review, which keeps the pipeline trustworthy at the source. For higher stakes, use GitHub Environments with required reviewers to add a human approval before production deploys, and consider a staging environment first so you catch issues away from real users. The goal is a pipeline you trust enough to use many times a day, because frequent small deploys are safer than rare large ones.