I have a rule that annoys people at the start of every project and thanks me by week three: nothing gets merged until the pipeline can deploy it. Not "we will set up CI once the feature is done." The empty application goes to a real environment through a real pipeline before anyone writes a line of business logic.
It sounds like over-engineering. It takes about a day. And it eliminates the worst week in software delivery — the one where a finished feature sits unreleased because nobody has ever deployed this thing and the credentials are on someone's laptop.
This is the setup I use on nearly every new project, in the order I build it, and the reasoning behind each piece.
Step One: A Dockerfile You Would Actually Run in Production
Most Dockerfiles I inherit are development Dockerfiles that ended up in production. They run as root, include the whole toolchain, and weigh 1.2GB. A multi-stage build fixes all of it and takes ten extra lines.
# ---- build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# ---- run ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/main.js"]
Three details carry most of the value. Copying package*.json before the source means dependency layers cache between builds, which is the difference between a 30-second build and a four-minute one. USER node means a container escape does not start as root. And a real health check means your orchestrator knows the difference between "the process is alive" and "the application is working" — which are very different things when a database connection has dropped.
The health endpoint should check dependencies, not just return 200. A service that cannot reach its database is unhealthy even though the process is running fine.
Step Two: Infrastructure in Terraform, Including the Boring Parts
The argument for infrastructure as code that convinces sceptics is not repeatability. It is code review. When someone opens a security group to the world, that shows up as a red line in a pull request that another human reads, instead of a click in a console at 11pm that nobody ever sees.
What I insist on from the first commit:
Remote state with locking. S3 plus DynamoDB, or the equivalent. Local state files are how two engineers destroy each other's infrastructure.
Separate state per environment. Not workspaces cleverly parameterised — genuinely separate. The purpose is that a mistake in staging cannot reach production, and shared state defeats that.
Plan on pull request, apply on merge. The plan output posted to the PR is the single most useful review artefact in the whole pipeline. You see exactly what will change before it changes.
No manual console changes, ever. The first time someone fixes something by hand in the console, your state is a lie and every future apply is a gamble. If it is urgent, do it by hand and then immediately backfill the code. Same day.
I keep modules small and boring. A module per logical piece — network, database, service — with explicit inputs. Clever Terraform is a liability; the person debugging it at 3am might be you, eight months from now, having forgotten everything.
Step Three: The Pipeline, With Security Built In Rather Than Bolted On
This is where DevSecOps stops being a word and becomes a set of jobs. The principle: every check that can block a merge runs on the pull request, and every check runs automatically, because a scan that requires someone to remember it is a scan that does not happen.
name: ci
on: [pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history for secret scanning
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
- run: npm audit --audit-level=high
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Secret scan
uses: gitleaks/gitleaks-action@v2
- name: IaC scan
run: docker run --rm -v "$PWD:/src" aquasec/trivy config /src
- name: Build and scan image
run: |
docker build -t app:${{ github.sha }} .
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --exit-code 1 --severity HIGH,CRITICAL app:${{ github.sha }}
A few things I have learned the hard way about this stage.
Scan the full git history for secrets, not just the diff. The credential that leaks is almost always the one committed eighteen months ago by someone who has since left.
Decide what actually blocks a merge, and stick to it. A pipeline that reports 340 findings and blocks nothing trains the team to ignore the pipeline. I block on high and critical severity with a documented, time-limited exception process, and everything else goes to a report we review on a schedule. Fewer gates that are respected beat many gates that are routinely overridden.
Keep the pull request pipeline under ten minutes. Past that, people stop waiting for it and start context-switching, and the feedback loop you built stops functioning. Parallelise jobs, cache dependencies, and move the slow end-to-end suite to a post-merge or nightly run.
Use OIDC for cloud credentials, not stored keys. GitHub Actions can assume an AWS role directly through federation. No long-lived access key in your repository secrets is a permanent class of breach removed.
Step Four: Deployment That Can Go Backwards
The question I ask before any first deploy is not "how do we ship this?" It is "how do we un-ship it in under five minutes?" If the answer involves a person reading a wiki page, the answer is not good enough.
What that requires in practice: immutable image tags, so latest never appears anywhere near production and you can always name the exact artefact to go back to. Backwards-compatible database migrations, because rolling back code is easy and rolling back a dropped column is not — add columns, deploy, backfill, and only remove the old one a release later. Health checks that gate the rollout, so a broken container never receives traffic. And a rolling or blue-green strategy, so a bad release affects a fraction of requests rather than all of them.
I also keep deploys small and frequent for a reason that has nothing to do with velocity: when you ship ten changes at once and something breaks, you have ten suspects. When you ship one, you have one. Deployment frequency is a debugging feature.
Kubernetes: When It Is Worth It
Predictable question, so here is my honest answer. Kubernetes is a superb answer to a problem most teams do not have yet.
If you are running three or four services with modest traffic, a managed container service — ECS Fargate, Cloud Run, App Service — will do the job with a fraction of the operational surface. You get containers, scaling, health checks and rolling deploys without owning a control plane, a service mesh conversation and a set of YAML files nobody fully understands.
The point where Kubernetes starts paying for itself is when you have enough services that scheduling becomes a real problem, several teams deploying independently, genuine multi-cloud or on-premise constraints, or at least one person whose actual job is the platform. Before that, the complexity buys you optionality you are not using.
What This Buys You
Set up in this order — container, infrastructure, pipeline, rollback — the whole thing costs about a day on a new project. What you get is that no deploy is ever a special event, no credential lives on anyone's machine, security findings appear while the code is still fresh in the author's head, and the person who joins in month four can ship on their second day.
None of it is clever. That is the point. The best pipeline is one nobody thinks about, because it has never surprised them.



