Skip to content

DevOps and Deployment

Your project runs on your laptop, and sooner or later it has to run somewhere else: on a teammate’s machine, at a URL or on a device your project partner can try, on a server that stays up after you close the lid. This page covers that path, from a setup any teammate can reproduce, through a CI workflow that checks every pull request, to automatic deployment, environments, and a rollback plan. It doesn’t cover the repository settings that make CI a required check, which are in Git and GitHub, or the security side of a deployment (secrets management and the misconfiguration checklist), which is in Security.

Continuous integration (CI) is, in Martin Fowler’s definition, the practice of merging everyone’s changes into the shared codebase at least daily and verifying each merge with an automated build that includes the tests. Continuous deployment (CD) takes the next step: every change that passes those checks goes to production automatically. Fowler distinguishes it from continuous delivery, where every change could go to production but a person decides when. DevOps is the wider set of practices that connect writing code to running it, CI and CD included.

Before anyone deploys, every teammate has to be able to run the project locally. Each difference between your machines (a Node version, a missing system library, a database at a different schema) becomes a bug someone debugs without knowing that’s what it is.

A good setup has three properties:

  1. Documented. A new teammate (or an AI coding tool) goes from cloning the repository to a running application by following the README. No Slack messages, no “ask Jordan, they know how to set it up.”
  2. Reproducible. Everyone runs the same dependency versions, the same database schema, and the same configuration.
  3. Fast. Setup takes minutes. If it needs six tools installed and three services configured by hand, script it.

Put a “Getting Started” section in your README that lists the prerequisites and the exact commands:

## Getting Started
### Prerequisites
- Node.js 24+ (recommend using [nvm](https://github.com/nvm-sh/nvm))
- PostgreSQL 16+
- A `.env` file (copy `.env.example` and fill in values)
### Setup
git clone https://github.com/your-team/your-project.git
cd your-project
cp .env.example .env # then edit with your local values
npm install
npm run db:migrate
npm run dev # starts the dev server at localhost:3000

If the project has several services (a frontend, a backend, a database), document how to start each one and in what order. The Documentation guide covers the rest of the README.

Docker packages an application with its runtime and system libraries into an image that runs the same way for everyone you share it with, and Docker Compose starts several containers together from one file. When a project depends on a specific database version, a message queue, or system libraries that are hard to install consistently, a Compose file gives the whole team the same stack:

compose.yaml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:password@db:5432/mydb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 5s
retries: 10
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

With this file, docker compose up starts the whole stack on any machine with Docker installed, which matters most when teammates use different operating systems. The healthcheck makes the application wait until PostgreSQL accepts connections, rather than only until its container has started.

Containers cost something too: build time, images to manage, and a layer between you and the process when you debug. If npm install && npm start works for everyone on the team, you don’t yet have the problem Docker solves. Adopt it when environment differences start causing bugs, when you need to match a production runtime exactly, or when your deployment target requires an image.

CI runs your checks (tests, linting, type checking, a build) on every push and pull request, so a problem shows up on the pull request that caused it instead of on main, where it blocks everyone.

GitHub Actions is the CI service built into GitHub. Its minutes are free on public repositories that use standard GitHub-hosted runners, and private repositories get a monthly quota that depends on the account’s plan; GitHub’s billing page has the current numbers. A workflow is a YAML file in .github/workflows/, written in the workflow syntax:

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Run tests
run: npm test
- name: Build
run: npm run build

This workflow runs on every push to main and every pull request that targets main. Each npm run step assumes a script of that name in your package.json. If any step fails, the run fails and the pull request shows a red check.

For a Python project, the same workflow swaps the setup step and the tools:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Lint
run: ruff check .
- name: Run tests
run: pytest

It assumes Ruff and pytest are listed in requirements-dev.txt, next to the application’s own requirements.txt. Python has no compile step, so for an application the install and the tests stand in for the build: the dependencies resolve and every tested module imports. If you publish a package, add a step that runs python -m build.

Run these on every pull request:

  • The test suite. If a test passes locally and fails in CI, treat it as a bug in the test: it depends on something on your machine that CI doesn’t have.
  • The linter, so the pipeline enforces style instead of everyone remembering to run it.
  • The build, because a pull request that passes its tests and breaks the build is still broken.

Add these when they fit your stack:

  • A type checker: tsc for TypeScript, mypy or Pyright for Python.
  • A dependency audit, npm audit or pip-audit, which flags dependencies with known vulnerabilities.

If you set up only one check, make it the tests. The 2017 State of DevOps Report is an industry report by Puppet and DORA, based on a survey of about 3,200 people (Forsgren et al., 2017). It recruited them by snowball sampling, asking respondents to pass the survey on to their networks, and notes that its sample is likely limited to organizations and teams familiar with DevOps (Forsgren et al., 2017). Five technical practices it tested all contributed to continuous delivery: test automation, deployment automation, continuous integration with trunk-based development, comprehensive version control, and integrating security into delivery work. Of the five, test automation contributed most (Forsgren et al., 2017).

A red run only reports a problem; on its own, it doesn’t stop anyone from merging. A branch ruleset that requires the check to pass turns it into a gate. Turn one on as soon as the workflow passes on main, because the longer the check stays optional, the more broken code you’ll have to fix on main before you can require it. The settings are in Git and GitHub: Protecting the Main Branch.

With CD, every merge to main deploys the application. It follows from CI and branch protection: if nothing reaches main without passing its checks, main is always deployable, and deploying it automatically removes the manual step where each teammate deploys a little differently.

A platform-as-a-service runs your application from your repository and handles the servers, HTTPS, and scaling. Each of these connects to GitHub: you point one at the repository, give it the build command and the output directory, and every push to main deploys. Pick by what you’re deploying:

  • A frontend, a static site, or a Next.js app: Vercel or Netlify.
  • A full-stack app with a database or background jobs: Render or Railway.
  • A Docker image: Fly.io.
  • Documentation or a static site with no server: GitHub Pages.

Check the current pricing page before you commit to one, because free and hobby tiers appear and disappear faster than a long project runs.

Vercel and Netlify also build a preview deployment for every pull request: a URL where a reviewer can use the change before it merges (Netlify calls them deploy previews). Both are on by default once the repository is connected. Review on the preview as well as the diff, because a reviewer who can click through the change sees problems the diff doesn’t show.

Some projects need more control than a PaaS gives: custom system packages, GPU access, specific network configuration, or long-running background processes. For those, deploy to a virtual machine, rented from a cloud provider or provided by your partner, and take on its operation:

  • SSH access: use SSH keys, not passwords, disable root login, and keep the OS updated.
  • Process management: run the application under systemd so it restarts when it crashes or the machine reboots.
  • A reverse proxy: put Caddy or Nginx in front of the application for HTTPS, static files, and routing.
  • Deployment: write a GitHub Actions workflow that connects over SSH, pulls the new version, and restarts the service, so deploying is still one merge.

A mobile app, a game, a desktop application, or firmware runs on the user’s own device, not on a server you operate, so deploying it means getting a build onto that device through a distribution channel. The CD idea carries over unchanged: on every merge to main, CI builds the release, signs it where the platform requires, and publishes it to a channel your testers install from, so nobody builds a release by hand on a laptop whose setup nobody else has. A store’s review and tester rules add clocks you don’t control, which Shipping covers. Pick the channel by what you ship:

  • A mobile app: a store’s test channel, Apple’s TestFlight or a Google Play testing track (internal, closed, or open). fastlane handles the code signing and the upload, and runs on a CI server.
  • A game: an itch.io page, which itch.io’s command-line uploader, butler, pushes builds to from a pipeline; or, for a game headed to Steam, a password-protected beta branch your testers opt into.
  • A desktop application: a GitHub release with the installer or binary for each platform attached.
  • Firmware: a release carrying the image the device is flashed from, with the flashing command in the README. Where the device updates itself over the air, the release is the image the update server delivers.

Install each new build on a device that has never run a developer build, because a developer’s machine already has the certificates, libraries, and settings a user’s machine lacks, and a build that only works where it was made hasn’t shipped.

The preview deployment has an equivalent too. For an Android package, a desktop binary, or a firmware image, have CI upload each pull request’s build as a workflow artifact, which a reviewer downloads from the run and installs before approving the change; for an iOS app, send each review build to TestFlight.

Use a managed database, because backups, upgrades, and security patches are operational work your team has no time for. If your partner already runs on a cloud, use their managed database (Amazon RDS, Cloud SQL, or Azure’s databases), because matching the stack they’ll maintain after you is worth more than a nicer developer experience. Otherwise, pick one of these:

  • (Preferred) The database add-on from your PaaS, since it deploys next to the application and needs no second account.
  • A managed PostgreSQL service such as Supabase or Neon, when your platform has no add-on for the database you need.
  • A database you run yourself on a VM, which gives you full control and hands you the backups and patching in return.

An application behaves differently in development, staging, and production. Environment variables carry those differences, so the code stays the same in all three.

Environments exist so a change can be observed somewhere it can’t hurt anyone before it reaches somewhere it can.

  • Development (local). Runs on each developer’s machine, with local databases, mock services, verbose logging, and debug tools.
  • Staging (optional). A deployed copy that mirrors production but isn’t public, for testing deployments and integrations before they reach real users.
  • Production. The live system users interact with, with real databases, real API keys, minimal logging, and error tracking.

Store configuration that changes between deploys (database URLs, API keys, the hostname) in environment variables, not in code, as the Twelve-Factor App’s config factor argues:

// Reads the connection string from the environment
const dbUrl = process.env.DATABASE_URL;

The same line with "postgres://user:password@localhost:5432/mydb" written into the code would point every environment at your laptop’s database and put the password in the repository.

Each environment has its own set of variables:

  • Local: in .env, which is never committed.
  • CI: in the workflow file, or in repository secrets for anything sensitive.
  • Production: in the hosting platform’s settings.

Change the database schema through migration files, never by hand. A migration is a version-controlled script that moves the database from one schema to the next:

Terminal window
# Create and apply a new migration locally
npx prisma migrate dev --name add-user-roles
# Apply migrations in production
npx prisma migrate deploy

Use the migration tool that comes with your ORM or framework: Prisma Migrate for JavaScript and TypeScript, Alembic for SQLAlchemy, Django migrations for Django. Follow the docs for the major version you install, because the commands change between versions: the two above are Prisma ORM 7’s (install prisma@7 and @prisma/client@7), and Prisma ORM 8 renames both.

Create each migration, run it locally, commit it, and let the deployment apply it to production. Every environment then has the same schema, and the migration history records each change, which is what you work from when one has to be undone.

Walk through this before your first production deployment and again whenever the setup changes.

  • The application builds and starts with production configuration.
  • Environment variables are set for production (database URL, API keys, secrets).
  • Logging captures errors without leaking sensitive data.
  • HTTPS is enabled (most PaaS providers handle this automatically).
  • The database is hosted and reachable from the production environment.
  • Database migrations have run against the production database.
  • DNS is configured if you use a custom domain.
  • The team knows how a deployment is triggered (automatic on merge, or manual steps).
  • The team knows how to roll back a deployment.
  • Error tracking is set up (Sentry, or at minimum centralized logging).
  • The team can read production logs.

Once the application is live, you need to know when it breaks, ideally before a user tells you.

An error tracker such as Sentry captures each unhandled exception with its stack trace and, for a web request, the request that triggered it, which is more than a console log on the hosting platform gives you. If you tell it about each release, it also shows the first release in which an error appeared, which usually points at the deployment to roll back.

For a small team, an error tracker plus the platform’s built-in logs is enough. A fuller monitoring stack is one more system you’d have to maintain.

Make every deployment reversible, because when one breaks production, restoring the last working version is faster than debugging forward while users wait. How you roll back depends on how you deploy:

  • PaaS platforms: Vercel, Netlify, and Render each roll back from the dashboard. Read your platform’s page before you need it, because they differ on what you can roll back to and what happens next:
    • Vercel stops sending new pushes to production until you promote a deployment.
    • After a dashboard rollback, Render turns off autodeploys until you re-enable them.
    • Netlify, with auto publishing on, replaces the version you rolled back to with the next Git-triggered deploy.
  • Containers: keep the previous image tagged. Rolling back means deploying it again.
  • VMs: if you deploy with git pull, roll back by checking out the previous commit and restarting the service. Script it, so it’s one command.
  • Store-distributed apps: you can’t take a build back from the devices that installed it. Release each update in stages instead, a staged rollout on Google Play or a phased release on the App Store, and halt or pause it when the update breaks. Halting limits the damage rather than undoing it: users who already have the update keep it, and a paused App Store release still reaches anyone who updates by hand. The fix ships as a new version. On Android, Google Play rejects a version code you’ve already used, and Android won’t install a lower one over a higher, so reverting means rebuilding the old code under a higher number.
  • Firmware: keep the previous image, and use a bootloader that can revert to it, because without one, a bad over-the-air update can leave a device in the field that won’t boot. MCUboot boots a new image as a test and swaps the old one back on the next reset unless the new image confirms itself. With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE set, ESP-IDF boots the previous app when a new one marks itself invalid or resets before marking itself valid.

Practice a rollback once on a quiet day, so the first real one isn’t also the first time anyone on the team opens that screen.

  • Automate anything you do more than twice. If every deploy means SSHing into a server and running the same three commands, put them in a script or a workflow.
  • Keep main deployable at all times, with CI and branch protection doing the enforcing, so deploying from it is always safe.
  • Use the same build command everywhere. If the application builds with npm run build locally, CI and production should run npm run build too, because a different command in each place hides the bugs only one of them triggers.
  • Keep infrastructure configuration in the repository. Compose files, workflows, proxy configuration, and deploy scripts go through the same review as application code.
  • Write the deployment process down, even when it’s automated: what happens when, how to trigger a deploy, and how to roll back. The teammate who set it up won’t always be there when it breaks.

The first deployment is the hard one. It’s where you meet DNS, TLS, and the hosting account’s limits (or, for a build users install, signing certificates and the store or release account), plus the build that works locally and fails on the platform, and every deployment after it reuses that work. The asymmetry tempts you to wait until there’s something worth deploying, which moves the hard part to the week of a demo, with a partner watching. Deploy an empty page in your first weeks instead. The cost is the same either way, but early in the project a failure costs a day of fiddling and nothing else. Shipping calls this a walking skeleton, the thinnest version of the system that runs end to end on a production URL, and puts it in the early phase.

When a deployment breaks, suspect the configuration before the code. Oppenheimer et al. (2003) studied user-visible failures at three large internet services and found operator error the leading cause in two of them. In all three, more than half of the operator errors behind those failures were configuration errors, and operator errors generally arose during changes to the system, such as deploying or upgrading software. On a small team’s project the same failure looks like a missing environment variable, a migration that never ran, debug mode left on, or a secret that exists on a laptop and not on the platform.

The Twelve-Factor App, a methodology its authors drew from their work on the Heroku platform, argues for designing against those failures. Read at least three of its factors. Factor III (config) keeps configuration strictly separate from the code, so a missing variable or a laptop-only secret is a setting to check rather than a code change. Factor X (dev/prod parity) keeps development, staging, and production as similar as possible, so less of what works locally fails on the platform. Factor XI (logs) treats logs as event streams, which is how you find the failures the other two don’t prevent.

Much of what DevOps means at a large company (platform teams, container clusters, multi-region failover) solves problems of scale your project doesn’t have, and Kubernetes is the clearest case: you almost certainly don’t need it. It fits containers onto a cluster of machines and handles their scaling and failover, which is a real problem for organizations running hundreds of services. What it costs is a large operational surface that someone has to understand, and on a small team that someone becomes the only person who can deploy. A platform-as-a-service that deploys on push gives you the part you need for a fraction of the attention. If you want Kubernetes experience for your resume, that’s a legitimate goal; call it a learning goal rather than an architectural requirement, and say so in your design document.

The 2017 State of DevOps Report introduced above also measured how often organizations deploy. It found high performers deploying on demand, multiple times a day, and low performers deploying between once a week and once a month (Forsgren et al., 2017). The report notes that its working figure of four deploys a day for high performers is conservative for companies like Etsy, which reports deploying 80 times a day, and Amazon and Netflix, which deploy thousands of times a day aggregated over the hundreds of services in their production environments (Forsgren et al., 2017).

In a 2006 interview with ACM Queue, Werner Vogels, then Amazon’s CTO, described each Amazon service’s team as responsible for it from design through operation: “You build it, you run it.” He argued that giving developers operational responsibility had improved the services’ quality, because it put them in daily contact with their software in production and with its customers. Your team is in that position already, since nobody else will deploy your system or fix it when it breaks.

In research, the equivalent of a reliable deployment is code someone else can build and run. Collberg and Proebsting (2016) examined 601 papers from ACM conferences and journals and, for those whose results were backed by code, tried to build the code they could obtain. Of the papers backed by code, research assistants built it within 30 minutes for 32.3%, and with “unlimited time” but without the lead developer’s help for 48.3%; the study stopped at the build and didn’t check the results. The authors found the lead developer often wasn’t available, for reasons such as the student responsible for the code having graduated or the authors’ email addresses no longer working. A pinned dependency list, a container image, and a setup section that works from a fresh clone address the same problem in both settings.

Sources and further reading

Activities that exercise this