---
title: "Deployment and DevOps in Depth: CI/CD, Docker, VPS, and Cloudflare Workers"
description: "How to ship a backend safely: build once and release, CI/CD with least privilege, Docker, a VPS with systemd and a proxy, Cloudflare Workers, and rollbacks."
canonical: "https://www.suriyaprakash.in/blog/engineering/deployment-devops-cicd-docker-vps-workers"
category: "Engineering"
date: "2026-10-03"
updated: "2026-10-03"
series: "Production Backend Systems"
tags:
  - deployment
  - devops
  - ci-cd
  - docker
  - vps
  - cloudflare-workers
  - rollbacks
  - backend
---

# Deployment and DevOps in Depth: CI/CD, Docker, VPS, and Cloudflare Workers

Canonical: https://www.suriyaprakash.in/blog/engineering/deployment-devops-cicd-docker-vps-workers

Nine posts in, the application is authenticated, authorized, guarded, observable, backed by a correct database and fast enough to survive its own success. None of that matters until it is running somewhere, and can be changed without a bad afternoon.

Deployment is where a lot of earlier decisions get tested together. Part 7's graceful shutdown decides whether a deploy drops requests. Part 8's migrations decide whether a rollback is possible. Part 4's secrets decide what an attacker gets if your repository or pipeline leaks. This last part is about the machinery that turns a commit into a running service: pipelines, containers, a virtual private server, and Cloudflare Workers.

As in the previous parts, I tested what I could run here and I mark everything else. I verified `npm ci` behavior, Wrangler's configuration handling (environments, variables, local secrets and a dry-run deploy), and the Wrangler command names against the installed CLI. The Docker, GitHub Actions, systemd and Caddy material comes from their official documentation, and I did not build a container or provision a server for this post, so those examples are written carefully but untested here.

## Quick answer

- **Build once, then release.** The build is an artifact; a release is that artifact plus configuration. Deploy the same artifact to every environment and change only the config.
- **Keep configuration and secrets out of the code,** and know the difference: configuration varies between deploys, secrets are the sensitive part of it.
- **A pipeline is code that holds your keys.** Give it least privilege, pin what it runs, and never run untrusted input through it.
- **Use `npm ci` in pipelines,** not `npm install`. It installs exactly what the lockfile says, or fails.
- **Every platform has the same three jobs:** run the process, restart it when it fails, and stop it cleanly when it is replaced.
- **Plan the rollback before the deploy.** Code rolls back easily. Data and migrations do not.
- **Know what is running.** Expose the build identifier, and mark deploys in your monitoring.

```txt
commit --> CI: install (npm ci) -> lint -> typecheck -> test -> BUILD (artifact)
                                                                |
                                  artifact + config(env) = RELEASE (immutable, has an id)
                                                                |
                              deploy: staging -> verify -> production (gradual if you can)
                                                                |
                                   health check  ->  ok: keep   |  bad: roll back to previous release
```

## Build, release, run

The twelve-factor methodology, a widely used set of guidelines for service design, separates deployment into three stages:

- **Build** converts a code repository, at a specific commit, into an executable bundle: dependencies fetched, assets compiled.
- **Release** combines a build with the current deploy's configuration. The result is ready to run.
- **Run** starts processes against a chosen release.

Two rules from that guidance are worth adopting even if you adopt nothing else. **Releases are immutable:** the guidance calls them an append-only ledger, and every release has a unique id. A change means a new release. That is what makes **rollback** simple and fast: you point back at an earlier release rather than trying to reverse a change. And **the run stage should have as few moving parts as possible,** because it may happen automatically, at 3 a.m., after a reboot or a crash, with nobody watching.

The practical consequence is that you should not build on the production server, and you should not build differently per environment. If staging runs a different artifact than production, staging tells you nothing about production.

## Configuration and secrets

The same guidance defines **config** as everything likely to vary between deploys (staging, production, a developer's laptop): database addresses, credentials for external services, the canonical hostname. It requires strict separation of config from code, and gives a useful test: could you open-source the codebase right now without exposing a credential? If not, config is leaking into code.

It recommends storing config in environment variables, for three reasons: they change between deploys without touching code, they are unlikely to be committed by accident (unlike config files), and they work in any language. It also warns against grouping variables into named environments like `development` and `production`, which multiplies as you add deploys.

On Cloudflare Workers this appears as two distinct things, and the documentation is explicit about the difference:

- **Variables (`vars`)** live in your configuration file, visible and committed. The docs say not to store sensitive information in them.
- **Secrets** are set outside the file and are not visible in Wrangler or the dashboard after you define them. To your Worker code, there is no difference between a variable and a secret; both appear on `env`.

For local development, Wrangler reads secrets from a `.dev.vars` (or `.env`) file next to the configuration. The documentation says not to commit it and to add `.dev.vars*` and `.env*` to `.gitignore`. I tested how this interacts with `vars`. A Worker with `LOG_LEVEL = "debug"` in the staging configuration, plus a `.dev.vars` file that set `LOG_LEVEL` and an `API_TOKEN`, ran locally with:

```txt
{"APP_ENV":"staging","LOG_LEVEL":"from-dev-vars","API_TOKEN_present":true, ...}
Using secrets defined in .dev.vars
```

The local file overrode the configured variable. That is convenient for development and a reminder that the value your code sees locally is not necessarily the one in the configuration you deploy.

### If a secret leaks

Treat any secret that appears in a repository, a log or a screenshot as compromised: rotate it, then clean up. Deleting a file in a later commit does not remove it from history. GitHub's guidance for workflow logs is the same: if an unredacted secret reaches a log, delete the log and rotate the secret. The most reliable protection is to prefer short-lived credentials that expire on their own, which is the idea behind OpenID Connect for pipelines, below.

## CI/CD: a pipeline is code that holds your keys

Continuous integration builds and tests every change. Continuous delivery turns a passing change into something deployable, and may deploy it automatically. A reasonable pipeline for a Node.js service:

1. **Install** exactly the locked dependencies.
2. **Lint and typecheck.**
3. **Test.**
4. **Build** the artifact once.
5. **Deploy to staging,** run smoke checks.
6. **Deploy to production** (often behind a manual approval or a protected branch).
7. **Verify** with a health check, and roll back automatically if it fails.

### `npm ci`, not `npm install`

In a pipeline you want the dependency tree to be exactly what you tested. I verified how the two commands differ on a small project:

| Situation | `npm ci` | `npm install` |
|---|---|---|
| `package.json` and lockfile agree | Installs the locked versions | Installs them |
| `package.json` changed, lockfile not updated | **Fails** with `EUSAGE`: the two "are not in sync" | Resolves again and **rewrites the lockfile** |
| Stray files already in `node_modules` | Removes `node_modules` first, so they are gone | Leaves them |
| Lockfile | Never written | May be updated |

That failure is the feature. A pipeline that silently resolves new versions on every run is not testing what you will ship. Commit the lockfile and install with `npm ci`.

### A least-privilege workflow

GitHub's security hardening guide for Actions gives the rules that matter most:

- **Restrict the default token.** Set the `GITHUB_TOKEN` default to read-only for repository contents and raise permissions per job only where needed.
- **Pin third-party actions.** The guide says tags can be moved or deleted if the action's repository is compromised, and recommends pinning to a full-length commit SHA (verifying the SHA belongs to the action's own repository and not a fork). It also says a lockfile-based approach, where available, is stronger. Audit what you run.
- **Do not store sensitive data as plaintext in workflows;** use individual secrets rather than structured blobs, because structured data can make log redaction fail.
- **Prefer OpenID Connect over long-lived cloud credentials** where the provider supports it, so the workflow requests short-lived, narrowly scoped tokens.
- **Never feed untrusted input directly into a shell script.** Set the value to an intermediate environment variable so it cannot change the generated script.
- **Avoid `pull_request_target`** unless necessary, and never check out untrusted code in workflows triggered by it.

An example that applies them (the action references are placeholders; substitute the full commit SHA of the release you have reviewed, and the deploy step to match your platform):

```yaml
name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read            # least privilege; raise per job if needed

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true   # a newer push supersedes an older run

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<full-commit-sha>        # pinned, not a movable tag
      - uses: actions/setup-node@<full-commit-sha>
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test
      - run: npm run build

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production   # protection rules and environment-scoped secrets
    steps:
      - uses: actions/checkout@<full-commit-sha>
      - run: npm ci
      - run: npx wrangler deploy --env production
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}   # scope it narrowly
```

I checked that this parses as valid YAML, which is a syntax check, not a test of the workflow. Two choices in it are deliberate: pull requests run the tests but never reach the `deploy` job or its secret, and the production job runs in a named environment where you can require approval.

## Containers with Docker

A container image packages your application and its runtime so it runs the same way everywhere. Docker's build best practices give the core guidance, which I have not exercised in this environment:

- **Use multi-stage builds** to keep the final image small: compile in one stage, copy only the result into a minimal runtime stage.
- **Choose a minimal base image,** and consider pinning to a specific digest for supply-chain integrity. The docs note that pinning means giving up automatic security fixes, so pair it with an update tool such as Dependabot.
- **Use a `.dockerignore`** so `node_modules`, `.git` and local env files never enter the build context.
- **Run as a non-root user** with `USER` if the service can.
- **Use the exec form of `CMD`** (`CMD ["node", "server.js"]`) so your application receives Unix signals directly.

A Dockerfile that follows those points (untested here):

```dockerfile
# ---- build stage
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

# ---- runtime stage
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
```

And the `.dockerignore`:

```txt
node_modules
.git
.env*
.dev.vars*
dist
```

Order matters for build speed: copy the manifests and run `npm ci` before copying the rest of the source, so a code-only change reuses the cached dependency layer.

### Containers and signals

This ties back to Part 7. When an orchestrator stops a container, it sends `SIGTERM` to the process with PID 1. Docker's guidance on the exec form says that is what lets the application receive signals. A Node process running as PID 1 has to handle `SIGTERM` itself (the shutdown sequence from Part 7), because on Linux the kernel does not apply default signal actions to PID 1 (a long-standing behavior I did not test here). `docker run --init`, or a minimal init process in the image, can forward signals and reap child processes for you. Also make sure the orchestrator's stop timeout is longer than your own drain timeout, or the container is killed in the middle of finishing requests.

A container does not remove the need for a restart policy, health checks and an update plan; it moves them into whatever runs your containers.

## A VPS: you run the whole stack

A virtual private server gives you a Linux machine and the responsibility for everything on it. The usual shape for a Node.js service is: a **reverse proxy** in front terminating TLS, your **application** running under a process supervisor, and a **database** on the same host or elsewhere.

### The process supervisor: systemd

`systemd` starts your app at boot, restarts it on failure, and stops it on request. A unit file:

```ini
# /etc/systemd/system/myapp.service
[Unit]
Description=My application
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=myapp
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node dist/server.js
Restart=on-failure
RestartSec=2
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
```

What the systemd documentation says about those lines:

- **`Restart=`** defaults to `no`. `on-failure` restarts the service when it exits with a non-zero code, is killed by a signal, or times out. This connects to Part 7: if your `uncaughtException` handler set `process.exitCode = 1` and exited, `on-failure` is what brings the service back. If the handler exited with 0, as the default handler behavior can, `on-failure` would not restart it.
- **`RestartSec=`** defaults to 100 ms, which restarts a crashing service almost instantly; a short delay avoids a tight crash loop hammering a dependency.
- **`TimeoutStopSec=`** is how long the service gets to stop after `SIGTERM` before it is killed with `SIGKILL`. Make it longer than your application's drain timeout. `KillSignal=` defaults to `SIGTERM`.
- **`Type=`** defaults to `simple`, which considers the unit started as soon as the process is forked, so some setup failures are not reported by `systemctl start`. `exec` waits until the binary has actually executed, and reports those errors.

Use `EnvironmentFile=` to keep configuration out of the unit and out of the repository, owned by root with restricted permissions.

### TLS and the reverse proxy

Do not expose a Node process directly on ports 80 and 443. Put a reverse proxy in front. Caddy is one option that handles certificates for you: its documentation says automatic HTTPS provisions TLS certificates and keeps them renewed, and redirects HTTP to HTTPS. It activates when Caddy knows a domain name it is serving. The ACME challenges it uses need port 80 (HTTP challenge) or port 443 (TLS-ALPN challenge) to be reachable from the internet, or a DNS challenge configured with your provider. A minimal configuration:

```txt
# /etc/caddy/Caddyfile
api.example.com {
    encode gzip
    reverse_proxy 127.0.0.1:3000
}
```

Nginx is equally common and needs certificates handled separately (for example with Certbot).

### The rest of the hardening list

These are standard practice rather than anything I verified here, so check each against your distribution's documentation:

- **Firewall:** allow only the ports you use (SSH, 80, 443); keep the application and database ports closed to the world.
- **SSH:** keys only, password authentication off, no direct root login.
- **Updates:** enable automatic security updates, and have a plan for kernel and runtime upgrades.
- **Backups:** copy data off the machine, and **test restoring them.** A backup you have never restored is a hope, not a backup.
- **Least privilege:** run the app as its own unprivileged user, as in the unit above.

### A simple release flow on a VPS

The build-release-run idea maps directly onto directories:

```txt
/srv/myapp/
  releases/
    2026-10-02T10-15-abc1234/     <- immutable release (artifact + deps)
    2026-10-03T14-02-def5678/
  current -> releases/2026-10-03T14-02-def5678     <- a symlink
```

A deploy uploads a new release directory, installs production dependencies with `npm ci`, runs database migrations (see below), repoints the `current` symlink, restarts the service, and checks health. **Rollback is repointing the symlink and restarting.** Keep the last few releases on disk so it is a one-command step. To avoid downtime during restarts, run two instances behind the proxy and restart them one at a time, with Part 7's graceful shutdown ensuring in-flight requests finish.

## Cloudflare Workers: no server to run

Workers run your code in Cloudflare's runtime without a server for you to manage. The operational model is different from the previous two options: there is no long-lived process to supervise or `SIGTERM` to handle, scaling is the platform's job, and your constraints come from the runtime (Part 4 covered the CPU-time limits: 10 ms per request on the Free plan, and 30 seconds by default on Paid, configurable up to 5 minutes).

### Configuration and environments

A Worker is described by a Wrangler configuration file. I created a small one with a variable, a D1 database binding and a staging environment, and ran `wrangler deploy --dry-run`, which builds and validates without uploading:

```jsonc
{
  "name": "demo-api",
  "main": "src/index.js",
  "compatibility_date": "2025-09-01",
  "compatibility_flags": ["nodejs_compat"],
  "vars": { "APP_ENV": "production", "LOG_LEVEL": "info" },
  "d1_databases": [{ "binding": "DB", "database_name": "demo", "database_id": "…" }],
  "env": {
    "staging": {
      "name": "demo-api-staging",
      "vars": { "APP_ENV": "staging", "LOG_LEVEL": "debug" },
      "d1_databases": [{ "binding": "DB", "database_name": "demo-staging", "database_id": "…" }]
    }
  }
}
```

Three behaviors from that test are worth knowing, because each prevents a real mistake:

1. **The dry run lists exactly what the Worker will have.** For the staging environment it printed the `DB` binding pointing at `demo-staging`, and `APP_ENV` as `"staging"`; for the default it showed `demo` and `"production"`. Reading that output before a deploy is a cheap way to catch a binding aimed at the wrong database.
2. **Wrangler asks you to name the target.** With multiple environments defined and no `--env`, it warned that deploying could affect the wrong environment and recommended an explicit `--env` flag (or `CLOUDFLARE_ENV`). Always pass it in scripts.
3. **Environments do not inherit.** I added a `qa` environment with no settings of its own. Wrangler warned that `vars` and `d1_databases` exist at the top level but not on `env.qa`, and that they are not inherited. A new environment starts empty, which is a safe default for bindings and an easy way to ship a Worker with missing configuration.

### Secrets

Secrets are set with `wrangler secret put <KEY>`. The documentation notes that this creates a new version and deploys it immediately; if you use gradual deployments, use `wrangler versions secret put <KEY>` and deploy the version separately.

### Versions, deployments, gradual rollout and rollback

Cloudflare separates two ideas. A **version** captures the complete state of the Worker at a point in time (code, static assets, bindings, compatibility settings) and has an id. A **deployment** determines which version or versions serve traffic. By default they are coupled: `wrangler deploy` uploads a version and deploys it to 100% of traffic in one step. You can decouple them, and a deployment can split traffic between two versions by percentage.

I checked the commands in the installed CLI (Wrangler 4.152.0) rather than from memory:

```bash
wrangler versions upload                      # upload a new version without deploying it
wrangler versions deploy <version-id>@10      # send 10% of traffic to it (omitted percentages share the rest)
wrangler versions list                        # recent versions
wrangler deployments status                   # what is serving production right now
wrangler rollback <version-id>                # go back to a previous version
wrangler tail                                 # stream logs from the running Worker
```

A gradual rollout with an error-rate alert (Part 7) is the safest way to ship risky changes: send a small share of traffic to the new version, watch the symptoms, then increase or roll back.

The caveat that matters most is in the documentation itself: **state changes for associated storage resources such as KV, R2, Durable Objects and D1 are not tracked with versions.** Rolling a Worker back restores its code and configuration, not your data. A migration that changed a D1 schema does not undo itself when you roll back the code, so the code you roll back to must still work with the schema you now have. That leads directly to the next topic.

## Deploying safely: the database is the hard part

Code is easy to roll back and data is not, which is why Part 8 ended with the rule that migrations run while old code is still serving traffic. The deployment-side version of that rule:

- **Make schema changes backward compatible.** Add before you remove: add the new column or table, deploy code that handles both shapes, backfill, switch reads, and only later drop the old structure.
- **Separate the migration from the code deploy,** and run it once, not once per instance.
- **Never roll back to code that cannot read the current schema.** If a rollback would break against the new schema, you do not have a rollback; you have a plan to fix forward.
- **Use feature flags** to separate "the code is deployed" from "the feature is on," so turning something off does not need a deploy.
- **Roll out gradually** when the platform allows it, and gate each step on health.

## Comparing the options

| | VPS + systemd | Containers (orchestrated) | Workers |
|---|---|---|---|
| You manage | The OS, proxy, TLS, updates, backups | Images and the orchestrator | Code and configuration |
| Process lifecycle | systemd (`Restart=`, `TimeoutStopSec=`) | Orchestrator restart policy, signals to PID 1 | The platform |
| Scaling | Larger machine, or more machines | Add replicas | Automatic |
| State | Local disk and DB under your control | External storage | Bindings (D1, KV, R2); storage is not versioned |
| Runtime limits | What the machine allows | What the image and limits allow | Platform limits (CPU time, APIs) |
| Rollback | Repoint a symlink, restart | Redeploy the previous image tag | `wrangler rollback`; data not rolled back |
| Best when | Full control, predictable load | Many services, standard tooling | Request/response workloads, minimal ops |

There is no universally right choice; the question is which operational burden you want to own.

## Knowing what is running

A surprising amount of incident time goes to answering "which version is live?" Make it trivial:

- **Expose the build identifier,** such as the commit SHA and build time, on a health or version endpoint, set at build time from your pipeline.
- **Mark deploys in monitoring** (Part 7), so a change in error rate lines up visibly with a release.
- **Keep health checks honest.** A health endpoint should reflect whether the instance can actually serve traffic; keep liveness checks trivial and readiness checks meaningful, as Part 7 described.
- **Log the release id** on every line (or at startup), so a log search tells you which version produced it.

## Debugging checklist

**The deploy "succeeded" but nothing changed.**

```bash
curl -s https://api.example.com/version        # does it show the new commit?
curl -sI https://api.example.com/ | grep -i -E 'age|cache-control'   # a cache (Part 9) serving the old response?
```

**Workers:** `wrangler deployments status` for what is live, `wrangler versions list` for what exists, `wrangler tail` for live logs, and `wrangler deploy --dry-run` to inspect bindings before shipping.

**VPS:**

```bash
systemctl status myapp                  # is it running; recent exit status
journalctl -u myapp -n 100 --no-pager   # its logs
sudo ss -tlnp | grep -E ':80|:443|:3000' # who is listening where
```

**Containers:** check the container's logs and exit code, whether it was stopped by `SIGKILL` (your shutdown took longer than the stop timeout), and whether the process runs as PID 1 and handles `SIGTERM`.

**Pipeline:** if `npm ci` fails with `EUSAGE`, the lockfile is out of sync with `package.json`; run `npm install` locally, commit the lockfile, and push.

**Production checklist:**

- One artifact built once, promoted through environments; configuration injected per environment.
- No secrets in the repository or logs; a rotation plan; short-lived credentials where possible.
- `npm ci` in CI; the lockfile committed.
- Workflow permissions read-only by default; actions pinned; no untrusted input in shell steps; deploy jobs not reachable from forks.
- Process supervision with restart on failure; stop timeout longer than the drain timeout; graceful shutdown handled.
- TLS terminated at a proxy; firewall, key-only SSH and automatic security updates on any server you run.
- Backups off-machine, and a restore you have actually tested.
- Backward-compatible migrations; a rollback plan that includes the data.
- Gradual rollout and health gating where available; explicit `--env` in every Workers deploy.
- A version endpoint, deploy markers in monitoring, and release ids in logs.

## Interview answer vs production answer

The interview answer: *"Set up CI to run tests and build, deploy with Docker or a platform, and use environment variables for config."*

The production answer is about what goes wrong around those steps:

- **Reproducibility is a decision.** `npm ci`, pinned actions and a single artifact are what make "it passed in CI" mean something.
- **A pipeline is part of your attack surface.** It holds deploy credentials and runs code from pull requests, so least privilege and untrusted-input rules apply to it as to the application.
- **Restart and shutdown are two halves of one design.** `Restart=on-failure`, exit codes, `SIGTERM` handling and stop timeouts have to agree with each other, or deploys drop requests and crashes go unnoticed.
- **Rollback is a data question.** Code reverts quickly; storage is not versioned with it, so compatibility between old code and the new schema is what makes rollback real.
- **The platform changes the work, not the responsibility.** On a VPS you patch the OS; on Workers you manage environments and bindings; either way you own the release process.

## FAQ

### Should I deploy on every merge to `main`?
Many teams do, with strong tests, health checks and an easy rollback. If your tests are thin or rollbacks are hard, put an approval step or a staging soak in front of production first.

### Why not just `npm install` in CI?
Because it may resolve and install different versions than the ones you tested and will rewrite the lockfile. `npm ci` installs exactly what the lockfile specifies and fails if `package.json` and the lockfile disagree, which I confirmed on a test project.

### What is the difference between a variable and a secret in Workers?
Both are available on `env` in your code. Variables are in the configuration file and visible; secrets are set separately and not readable in Wrangler or the dashboard after you define them. Put anything sensitive in a secret.

### Do I need Docker?
Not necessarily. A container gives you a reproducible runtime and portability, and a larger operational surface to understand. For a single service on one server, a build artifact plus systemd can be simpler. For many services or an orchestrated platform, containers are the standard unit.

### How do I deploy without downtime on one server?
Run at least two instances behind the proxy, restart them one at a time, and make sure each drains in-flight requests on shutdown (Part 7). With a single instance, a restart is a brief interruption unless you add a mechanism that hands the listening socket to the new process.

### Can I roll back a Worker?
Yes: `wrangler rollback <version-id>` returns to a previous version. It restores code and configuration; it does not undo changes to D1, KV, R2 or Durable Objects, because those are not tracked with versions.

### Where should `.dev.vars` and `.env` files live?
Next to the Wrangler configuration, and out of git. In my test, `.dev.vars` values overrode the configured variables locally, so do not assume your local environment matches what you deploy.

### How often should I rotate secrets?
At least whenever someone with access leaves, a secret is exposed, or a tool that held it is compromised. Short-lived, automatically issued credentials reduce how much rotation you have to do by hand.

## Glossary

| Term | Simple meaning |
|---|---|
| CI | Continuous integration: build and test every change |
| CD | Continuous delivery/deployment: make passing changes releasable or release them |
| Artifact / build | The packaged output of a build, deployed unchanged to every environment |
| Release | A build combined with a deploy's configuration; immutable and identified |
| Config vs secret | Per-deploy settings vs the sensitive subset of them |
| OIDC | A way for a pipeline to get short-lived credentials without stored keys |
| Multi-stage build | A Dockerfile pattern that builds in one stage and ships only the result |
| PID 1 | The first process in a container; it receives the stop signal |
| systemd | The Linux service manager that starts, restarts and stops processes |
| Reverse proxy | A server in front of your app that terminates TLS and forwards requests |
| Worker version / deployment | A snapshot of a Worker / the version(s) currently serving traffic |
| Gradual deployment | Sending a percentage of traffic to a new version before going to 100% |
| Rollback | Returning to a previous release |
| Backward-compatible migration | A schema change that old and new code can both run against |
| Feature flag | A switch that turns behavior on or off without a deploy |

## The end-to-end mental model

```txt
  developer                CI (least privilege, pinned, npm ci)                    environments
  ---------   push  ->    install -> lint -> typecheck -> test -> BUILD ONCE
                                                              |
                                                              v
                                              artifact  +  config/secrets (per env, not in code)
                                                              |
                                  +---------------------------+----------------------------+
                                  v                           v                            v
                           VPS: release dir          Container: image            Worker: version
                           symlink + systemd         orchestrator + SIGTERM      wrangler deploy / versions
                                  \                           |                            /
                                   +--------- health check, version endpoint, deploy marker (Part 7) -------+
                                                              |
                                          healthy -> keep  |  unhealthy -> roll back (code)  
                                                              |
                                   data/schema: backward-compatible migrations make the rollback real
```

## Closing the series

This series started with a single question: who is calling, and what may they do? Part 1 separated authentication from authorization, and Parts 2 to 4 followed identity through sessions, tokens, federated login and passwords with a second factor. Part 5 turned permissions into rules that filter data as well as check access. Part 6 guarded the entrance, Part 7 made failures visible, Part 8 kept the data correct under concurrency, Part 9 made it fast without making it wrong, and this part put it all in front of real users and made it possible to change.

The thread running through all ten is the same one: a backend is not a pile of features, it is a set of promises (that the right person sees the right data, that a failure is noticed, that a deploy can be undone), and each promise has a specific way of being quietly broken. The most useful habit across the whole series is the one I tried to follow in every post: when you can test a claim, test it, and when you cannot, say so.
