CI/CD pipeline best practices: from git push to production
The CI/CD practices that actually matter: trunk-based development, build-once artefacts, safe migrations, and a working GitHub Actions example.
On this page
- Trunk-based development beats long-lived feature branches
- Fast feedback: a pipeline people actually wait for
- Build once, promote the same artefact
- Match your environments
- Handle secrets properly
- Database migrations that don't require downtime
- The test pyramid: where to put your effort
- Deployment strategies: pick one on purpose
- Rollbacks: plan the un-deploy before you need it
- A minimal, correct GitHub Actions workflow
- A CI/CD best practices maturity checklist
- What to do next
CI/CD best practices come down to a short list that's easy to state and genuinely hard to skip under deadline pressure: integrate small changes constantly, build one artefact and promote it unchanged, keep environments alike, automate the tests that actually catch regressions, and know how to roll back before you need to. This post covers each one, plus a minimal, correct GitHub Actions workflow for deploying a Node.js/Next.js app to a Linux server over SSH. It pairs with our wider guide to DevOps as a service if you're deciding how much of this to build yourselves versus bring in help for.
Trunk-based development beats long-lived feature branches#
Trunk-based development means everyone commits to a single shared branch frequently — multiple times a day for an active team — instead of working for days or weeks on a private feature branch. Short-lived branches (hours, not weeks) are fine for code review; what causes pain is a branch that drifts far enough from the trunk that merging it becomes an event. DORA's research (the group behind the annual State of DevOps studies) associates short branch lifetimes with higher-performing teams, not as a stylistic preference but as a measured outcome. If a feature isn't ready to ship, hide it behind a feature flag rather than a long-lived branch.
Fast feedback: a pipeline people actually wait for#
Martin Fowler's original description of continuous integration is still the right bar: everyone integrates against the mainline at least daily, and every integration is verified by an automated build, including tests, to catch problems immediately rather than days later. If your pipeline takes 40 minutes, people will batch up changes and stop waiting for it — which quietly undoes the whole point. Split slow test suites so quick checks (lint, unit tests) gate the pull request in a few minutes, and push slower integration or end-to-end suites to run in parallel or just before deploy.
Build once, promote the same artefact#
Build a single artefact — a container image, a tarball, a compiled binary — and move that exact artefact through staging and into production, rather than rebuilding from source at each stage. The Twelve-Factor App's build/release/run separation describes why: a build turns code into an executable bundle, and a release combines that build with environment-specific config. If you rebuild per environment, you can no longer be certain the code that passed staging is the code running in production — a dependency could resolve differently, or a compiler flag could differ. Combine the artefact with per-environment configuration at deploy time instead of baking config into the build.
Match your environments#
"Works on staging" only means something if staging resembles production: same OS version, same runtime version, same database engine and version, similar data volume. Differences here are one of the most common sources of a deployment that passes every check and then fails in production. You don't need staging to be full-scale — you need it to be structurally the same.
Handle secrets properly#
Never put credentials in a repository, a Dockerfile, or a build log. GitHub Actions provides an encrypted secret store for exactly this — reference secrets through the secrets context, pass them as environment variables rather than command-line arguments (command-line arguments can leak through process listings), and use GitHub's ::add-mask:: for any sensitive value your pipeline generates that isn't already a stored secret. Use environment-scoped secrets and required reviewers on your production environment if a deploy should need a second set of eyes before it runs.
Database migrations that don't require downtime#
The riskiest CI/CD failures are usually schema changes, because a database can't be "rolled back" as cleanly as an application deploy. Martin Fowler's parallel change (expand-contract) pattern solves this by splitting a breaking change into safe steps:
- Expand — add the new column, table or index without removing the old one. Old and new code both keep working.
- Migrate — deploy application code that writes to (and eventually reads from) the new structure, and backfill existing data.
- Contract — once nothing depends on the old structure, remove it in its own, separate deploy.
This means a schema change is never a single, all-or-nothing step tied to an application deploy — which is what makes rollback possible at every stage.
The test pyramid: where to put your effort#
Martin Fowler's test pyramid argues for far more unit tests than end-to-end ones: a broad base of fast, cheap unit tests, a smaller layer of service/integration tests, and a thin top layer of full end-to-end UI tests. End-to-end tests are brittle, slow, and expensive to maintain — useful as a final check on critical user journeys, not as your main line of defence. If your test suite is shaped like an inverted pyramid (mostly slow UI tests), that's usually why the pipeline feels painful rather than helpful.
Deployment strategies: pick one on purpose#
A basic pipeline restarts the app with the new version, which means a few seconds of downtime per deploy — acceptable for many small apps, not for something that can't drop a request. Blue-green, rolling and canary deployments each remove that gap in different ways, at different levels of infrastructure complexity. We cover the trade-offs in detail in zero-downtime deployments: blue-green, rolling and canary explained — decide deliberately rather than defaulting to whichever your first tutorial used.
Rollbacks: plan the un-deploy before you need it#
A rollback plan written during an incident is worse than one written in advance. At minimum, know: which previous artefact/tag you'd redeploy, whether your last migration is backward-compatible with the previous code version (this is exactly what expand-contract buys you), and who can execute it without waiting for the one person who usually does. Keep the last few build artefacts available (as in the workflow below) so "roll back" means redeploying a known-good tarball, not rebuilding from an old commit under pressure.
A minimal, correct GitHub Actions workflow#
This deploys a Node.js/Next.js app to a single Linux server over SSH: build once, upload the artefact, then release it. Store DEPLOY_HOST, DEPLOY_USER and DEPLOY_SSH_KEY as GitHub Actions secrets first. The action versions below (@v7) are current as at September 2026 — check each action's marketplace page for the latest major version before you copy this in.
name: Deploy to production
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npm test
- run: npm run build
- name: Package release
run: tar -czf release.tar.gz .next public package.json package-lock.json next.config.ts
- uses: actions/upload-artifact@v7
with:
name: release
path: release.tar.gz
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v7
with:
name: release
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- name: Upload and release
env:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
scp -i ~/.ssh/deploy_key release.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:/srv/app/releases/release-${{ github.sha }}.tar.gz"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" "
set -e &&
rm -rf /srv/app/current && mkdir -p /srv/app/current &&
tar -xzf /srv/app/releases/release-${{ github.sha }}.tar.gz -C /srv/app/current &&
cd /srv/app/current && npm ci --omit=dev &&
sudo systemctl restart myapp &&
sleep 3 && curl -sf http://localhost:3000/ > /dev/null
"This is deliberately simple: one restart, one health check, artefacts kept on the server by commit SHA so a rollback is tar -xzf release-<previous-sha>.tar.gz and a restart. Add a blue-green or rolling step once a few seconds of downtime per deploy actually matters to your users.
A CI/CD best practices maturity checklist#
Level | What's in place |
|---|---|
Ad hoc | Deploys are manual; "works on my machine" is a real risk |
Basic | Automated build and tests run on every push; deploy is still a manual trigger |
Continuous delivery | Every passing build is automatically deployable; deploy to production is a deliberate, low-effort action |
Continuous deployment | Every passing build on the trunk deploys automatically, with health checks and automatic or one-click rollback |
Mature | The above, plus trunk-based development, backward-compatible migrations by default, and deployment metrics (frequency, lead time, change failure rate, time to restore) tracked and reviewed |
What to do next#
Start with whichever gap on the maturity checklist is costing you the most right now — usually it's either "deploys are still manual" or "we don't trust our tests enough to skip a manual check." If you'd rather have a pipeline like this built and kept current for you, alongside the server it deploys to, see how we approach CI/CD automation and managed DevOps more broadly, or read how a build-once approach carries through to containers in Docker to production.
Frequently asked questions
What does CI/CD actually stand for and mean?
Continuous Integration is merging code into a shared branch frequently, with an automated build and test on every merge. Continuous Delivery (or Deployment) extends that so every change that passes the pipeline is automatically releasable, and in the 'Deployment' variant, automatically released to production without a manual step.
Is trunk-based development realistic for a small team?
It's often easier for a small team than for a large one — there are fewer people to coordinate. The practice is short-lived branches (hours to a day or two, not weeks) merged behind feature flags if a change isn't ready to ship, rather than one person's branch drifting from main for a month.
Do we need Kubernetes or a complex platform to do CI/CD properly?
No. A correct pipeline is defined by the practices — build once, test automatically, deploy the same artefact everywhere, roll back quickly — not by the platform. A single Linux server with a GitHub Actions workflow deploying over SSH, as shown below, meets all of them.
How do we handle database migrations in a CI/CD pipeline?
Make each migration backward-compatible with the currently deployed code, using the expand-migrate-contract pattern: add the new column or table without removing the old one, deploy code that can use either, backfill data, then remove the old structure in a later, separate step once nothing depends on it.
What's the minimum test coverage we need before automating deployment?
There's no fixed percentage that makes automation safe — a small set of tests covering your critical paths (login, checkout, core API endpoints) automated and trusted is worth more than a large suite nobody trusts. Start there, and grow coverage as you find bugs the pipeline should have caught.
Sources
- Trunk Based Development — accessed 18 September 2026
- Continuous Integration — Martin Fowler — accessed 18 September 2026
- V. Build, release, run — The Twelve-Factor App — accessed 18 September 2026
- DORA's software delivery metrics: the four keys — accessed 18 September 2026
- Using secrets in GitHub Actions — GitHub Docs — accessed 18 September 2026
- actions/checkout — GitHub — accessed 18 September 2026
- actions/setup-node — GitHub — accessed 18 September 2026
- actions/upload-artifact — GitHub — accessed 18 September 2026
- TestPyramid — Martin Fowler — accessed 18 September 2026
- ParallelChange — Martin Fowler — accessed 18 September 2026
Facts in this article were last checked on 18 September 2026.
Inventure Engineering Team
Engineers at Inventure Technologies who build, host and run software for clients in Nepal and Australia. We write about what we do every day.
Keep reading
DevOps as a service: what it is, what it costs, and when it beats hiring
What managed DevOps includes, how pricing and engagement models work, and a cost framework for comparing DevOps as a service with hiring in-house.
Read articleManaged DevOps vs an in-house team vs freelancers: cost and risk compared
A fair, cost-and-risk comparison of managed DevOps services, an in-house team and freelancers, so you can match the model to your stage of growth.
Read articleDo you really need Kubernetes? A decision guide for start-ups and SMEs
What Kubernetes actually solves, its real operational cost, and simpler alternatives, with a decision table to tell you if you need it yet.
Read articleWant engineers who handle this for you?
We build, host and run software for teams in Nepal and Australia — with dedicated support on every plan.