Cloud

Cloud Platforms

Cloud deployment is the art of choosing how much infrastructure to manage. From a raw VPS to a managed PaaS to Kubernetes, the trade is the same: control and cost against the time you spend keeping it alive.

intermediate14 min readUpdated Sep 16, 2026
Dockerfile
dockerfile
# Dockerfile
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Spectrum
VPS, PaaS, containers, serverless
Managed means
Patching, scaling and backups
Configuration
Environment variables, not files
State
Stateless instances, external stores
Health checks
Liveness and readiness probes
Rollback
Redeploy the previous release

Why it matters

What the cloud actually gives you

Infrastructure becomes someone else's job

The platform patches the kernel, terminates TLS, balances traffic and restarts failed instances, so your team spends its time on the product instead of the operating system.

One image, any host

A container built once runs the same on a laptop, a PaaS and a managed orchestrator, which keeps the deployment story portable and the surprises small.

Scaling and observability are built in

Add instances when traffic rises, read logs and metrics from one place, and let health checks remove a broken instance before users notice it.

The big picture

The three layers of a deployment

You package the app as an image, hand it to a platform that runs it, and keep all state in managed stores beside it.

The image

Package

The application and its runtime are frozen into an immutable image, tagged by commit, that every environment runs unchanged.

The platform

Run

A PaaS, a managed container service or a cluster schedules the image, restarts it when it fails and scales it when load changes.

The data layer

Persist

State lives outside the instance in managed Postgres, Redis and object storage, so instances can be replaced at any moment.

Flow

From commit to a running release

This is the path a change takes once CI is green, and the same steps apply whether the platform is Fly.io, Cloud Run or Kubernetes.

  1. 1

    Build the image

    The pipeline builds a multi-stage image tagged with the commit, keeping build tools out of the runtime layer.

  2. 2

    Push to a registry

    The image is pushed to a registry and referenced by an immutable digest that never changes.

  3. 3

    Provision or update the service

    The platform is told about the new digest, either by an API call, a config file or a manifest applied to the cluster.

  4. 4

    Run migrations

    Backward-compatible schema migrations run once, before the new version starts serving traffic.

  5. 5

    Release the new version

    New instances start alongside the old ones, and traffic is shifted only once they report healthy.

  6. 6

    Pass a health check

    A readiness probe confirms the instance can reach its database and dependencies before it receives requests.

  7. 7

    Shift traffic and keep the old version

    The previous release stays available so a rollback is a routing change rather than a rebuild.

The complete guide

Cloud Platforms: Everything you need to know

The deployment spectrum

There is no single “cloud”. There is a spectrum of how much of the machine you own, and every product sits somewhere on it.

A virtual private server (VPS) is a machine you rent and administer. You install the runtime, configure a reverse proxy, manage TLS certificates and keep the OS patched. It is cheap, flexible and completely yours to break. It is the right choice when you need a specific runtime or want to understand every layer.

A platform as a service (PaaS) such as Railway, Render or Fly.io takes your code or image and runs it. You declare a port and a health check; the platform handles TLS, routing, restarts and scaling. This is where most small teams should start.

Managed containers such as AWS ECS, Google Cloud Run or Azure Container Apps run a container image with more knobs than a PaaS: networking, IAM roles, autoscaling rules. They sit between a PaaS and a cluster in operational weight.

Serverless functions such as AWS Lambda or Cloudflare Workers run a function per request, scale to zero and bill per invocation. They are excellent for spiky, short-lived work and awkward for long-lived connections and heavy CPU.

Kubernetes is a general-purpose orchestrator. It offers the most control and the most surface area. It is justified when you run many services with a platform team; it is overkill for a single API.

The way to choose is to start at the managed end and move toward control only when a concrete limitation appears. The most common mistake is adopting Kubernetes for one service because it feels like the serious choice.

Option You manage Good for Watch out for
VPS OS, runtime, proxy, TLS Full control, custom runtimes Patching, failover, on-call
PaaS Just the application Small teams, fast iteration Less control, per-unit cost
Managed containers Image, IAM, scaling rules Services that need knobs More configuration than a PaaS
Serverless A function at a time Spiky, short-lived work Cold starts and time limits
Kubernetes Everything above the kernel Many services, platform teams Real operational load

What “managed” actually buys you

The word managed hides a list of jobs that stop being yours.

  • Patching. The kernel, the runtime and the base image are updated without a maintenance window you scheduled.
  • TLS. Certificates are issued and renewed automatically, and HTTP is redirected to HTTPS.
  • Scaling. Instances are added when CPU or request count crosses a threshold and removed when it falls.
  • Health and restarts. A process that crashes or fails its health check is replaced without a human.
  • Logs and metrics. Output is collected centrally and queryable, instead of living in a file on a box you SSH into.
  • Backups. Managed databases take snapshots and support point-in-time recovery.

What you give up is control and some cost efficiency. You cannot tune the kernel, install arbitrary system packages, or squeeze the last dollar out of a machine. For most teams that is a good trade: the alternative is an on-call rotation for infrastructure you are not specialised in.

The exception is data. Managed Postgres, Redis and object storage are almost always worth it. Building reliable backups, failover and replication yourself is a project, and the managed version is usually cheaper than the engineer-hours it saves.

Twelve-factor thinking

The twelve-factor app is an old checklist that still describes cloud-native deployment accurately. Three of its factors matter most day to day.

Config in the environment. Anything that differs between deployments — database URLs, API keys, feature flags — comes from environment variables, not files committed to the repository. This is what lets one image run in every environment.

DATABASE_URL=postgres://user:pass@host:5432/app
REDIS_URL=redis://host:6379
PORT=8080

Stateless processes. An instance holds no durable state. It can be started, stopped, duplicated and destroyed freely. Sessions, uploads and caches live in external services.

Logs as event streams. The application writes structured lines to stdout and does nothing else with them. The platform collects, stores and searches them. No log files to rotate, no disk to fill.

A fourth factor is worth calling out: disposability. Start fast and shut down gracefully. A process that takes a minute to boot makes scaling and deploys slow; one that ignores SIGTERM drops in-flight requests.

Building a small container image

A good production image is small, reproducible and runs as a non-root user. A multi-stage build gets all three.

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

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

The build stage installs everything, compiles and then prunes development dependencies. The runtime stage copies only node_modules, dist and the manifest. TypeScript, test frameworks and source files never reach production.

USER node drops root, so a compromised process cannot easily escalate. NODE_ENV=production turns off development behaviour and enables framework optimisations. Pin the base image to a specific variant, and prefer bookworm-slim or a distroless image over a full Debian install to shrink the attack surface.

Add a .dockerignore so the build context stays small:

node_modules
.git
dist
.env

A smaller image pulls faster, which directly shortens cold starts and deploy times.

Environment configuration and secrets

Configuration should be injected, never baked in. The same image must run in staging and production with only the environment changing.

Platforms differ in the mechanism, but the shape is the same. A config file declares non-secret values and a secret store holds the sensitive ones.

[env]
  NODE_ENV = "production"
  LOG_LEVEL = "info"

[[services]]
  internal_port = 8080

Secrets are set separately and never committed:

fly secrets set DATABASE_URL=postgres://...
fly secrets set STRIPE_SECRET_KEY=sk_live_...

On Kubernetes, secrets arrive as environment variables or mounted files:

envFrom:
  - secretRef:
      name: api-secrets

Two habits keep this safe. First, validate configuration at startup and fail loudly if something is missing, rather than throwing on the first request that needs it. Second, prefer identity-based access: a workload identity or instance role lets the app fetch short-lived credentials without any static secret at all.

import { z } from "zod";

const Env = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

const parsed = Env.safeParse(process.env);

if (!parsed.success) {
  console.error("invalid configuration", parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const config = parsed.data;

The process refuses to start with an incomplete environment, which turns a class of runtime surprises into an immediate, obvious deploy failure. Never log the environment itself: a single debug line that dumps process.env can leak every secret the service holds.

Zero-downtime releases and health checks

A deploy that drops requests is a deploy users notice. Zero-downtime releases depend on two things: starting new instances before stopping old ones, and knowing when a new instance is actually ready.

Readiness means the instance can serve traffic. It has connected to the database, warmed its caches and finished starting. Only then should the load balancer route to it.

Liveness means the instance is still healthy. If it fails repeatedly, the platform kills and replaces it.

app.get("/healthz", async (req, res) => {
  try {
    await db.query("select 1");
    res.status(200).json({ status: "ok" });
  } catch {
    res.status(503).json({ status: "unhealthy" });
  }
});

Keep the health check cheap and honest. Checking the database is reasonable; calling five downstream services is not, because it turns a blip elsewhere into a restart loop. If a dependency is optional, report healthy and degrade gracefully.

Graceful shutdown is the other half. On SIGTERM, stop accepting new connections, finish in-flight requests, close the database pool and exit. Give the platform a grace period slightly longer than the slowest request.

process.on("SIGTERM", () => {
  server.close(async () => {
    await db.end();
    await redis.quit();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 15_000).unref();
});

The timeout is a backstop: if something hangs, the process still exits before the platform’s grace period expires and the platform resorts to SIGKILL.

Horizontal scaling and statelessness

Scaling horizontally means running more copies of the same instance. It works only if the instances are interchangeable, which requires that none of them holds unique state.

State belongs in services designed for it:

  • Sessions in Redis, not in memory or a signed cookie alone.
  • Uploads in object storage such as S3 or R2, not on local disk.
  • Caches in Redis or a CDN, not in a process-local map.
  • Background jobs in a queue with dedicated workers, not on a timer inside the web process.

Once state is external, scaling is a number. The platform adds instances under load and removes them when it falls, and any instance can serve any request.

Autoscaling needs a signal. CPU is the common default, but for I/O-bound Node services, request concurrency or queue depth often tracks load better. Scale on the metric that actually predicts saturation, and always set a minimum instance count so the service survives a traffic spike while new instances boot.

Remember the connection problem: every instance opens its own database pool. Twenty instances with a pool of twenty need four hundred connections, which Postgres will not grant. Cap the pool per instance and put a pooler in front of the database.

Managed Postgres and Redis

The database is the part of the stack least suited to running yourself and most tempting to run yourself anyway. A managed Postgres gives you automated backups, point-in-time recovery, a failover standby and often read replicas, with none of the operational work.

Two rules keep it healthy. First, size connections deliberately. Set max on the pool to a small number per instance and use a pooler such as PgBouncer for many-instance deployments. Second, run migrations as a release step, not on application boot. If every instance migrates on startup, a rolling deploy runs the same migration five times concurrently.

# run once, before the new version starts
npm run db:migrate

Managed Redis is the natural home for sessions, rate-limit counters and job queues. Enable persistence if the data matters, and treat the instance as a shared dependency whose latency affects every request. Keep it in the same region as the application; a cross-region hop on every cache read is a tax you will feel.

DNS, TLS and custom domains

DNS maps a name to the platform, and TLS makes the connection trustworthy. Both are largely automated now, but the details still matter.

Point an A or ALIAS record at the platform’s address, or a CNAME at its hostname. Use a short TTL while migrating so a mistake is quick to fix, then raise it. Keep the apex domain and the www host consistent, redirecting one to the other so links and cookies do not fragment across two origins.

TLS is issued and renewed automatically by most platforms. Force HTTPS, enable HSTS once you are confident, and make sure the app trusts the proxy’s forwarding headers so it generates https URLs and sees the real client IP.

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;

When you run your own reverse proxy, the Nginx guide covers these headers, TLS termination and upstream configuration in detail. Getting them wrong causes redirect loops and rate limiters that see every request coming from the proxy’s IP.

Observability and alerting

You cannot operate what you cannot see. Three signal types cover most needs.

Logs are structured events. Emit JSON with a level, a message and a request id so they can be filtered and correlated. Write to stdout and let the platform collect them.

console.log(JSON.stringify({ level: "info", msg: "request", id, path, ms }));

Metrics are numbers over time: request rate, error rate, latency percentiles, CPU, memory and queue depth. Track the four golden signals — latency, traffic, errors and saturation — for every service.

Traces follow one request across services and make a slow fan-out visible. They matter more as the number of services grows; for a single API, good logs and metrics usually suffice.

Alert on symptoms users feel, not on causes. A rising error rate or p95 latency above a threshold is worth waking someone for. High CPU that has not affected requests is a dashboard note, not a page. Every alert should be actionable, or it trains people to ignore alerts.

Cost awareness

Cloud bills grow in the gaps between decisions. The usual culprits are predictable.

Egress is often the largest surprise. Data leaving the provider is billed, sometimes heavily, while data coming in is usually free. Serve large assets through a CDN, compress responses, and keep chatty services in the same region.

Idle resources are easy to forget: a staging database left running, unattached volumes, old snapshots, oversized instances kept “just in case”. Scale down non-production environments outside working hours.

Over-provisioning wastes money in the other direction. Right-size instances by measuring actual usage, and let autoscaling handle peaks instead of paying for the worst case around the clock.

Serverless trade-offs. Functions that scale to zero are cheap when idle but can be expensive under steady load compared with a small always-on container. Model both if traffic is predictable.

Set a budget alert on the account. A bill that doubles quietly is far worse than a notification that a threshold was crossed.

Infrastructure as Code

Clicking through a console is fine for the first deploy and a liability afterwards. Infrastructure as code records the desired state in files, so environments are reproducible, reviewable and recoverable.

Terraform and OpenTofu describe resources declaratively. Pulumi uses real programming languages. Kubernetes manifests and PaaS config files are simpler forms of the same idea. The tool matters less than the practice: the definition lives in version control and is applied by a pipeline.

resource "aws_db_instance" "main" {
  engine                  = "postgres"
  instance_class          = "db.t4g.small"
  allocated_storage       = 50
  backup_retention_period = 7
}

Two rules make IaC safe. Keep state remote and locked so two people cannot apply conflicting changes. Review plans before applying, because a plan that intends to destroy a database should stop a human, not proceed silently.

For a single PaaS service, a fly.toml or render.yaml committed to the repository is already infrastructure as code. Start there and adopt a full tool when the surface grows.

Rollbacks and forward fixes

Every deploy needs a way back. Because the artifact is immutable and tagged by commit, rolling back is redeploying the previous digest.

fly releases
fly deploy --image ghcr.io/me/app@sha256:previous

On Kubernetes, a rollout undo returns to the previous revision. On a PaaS, most platforms keep a list of releases and let you redeploy one with a click.

Rollbacks are fast but not always sufficient. If a database migration already ran and changed the schema, the old code must still be able to read it. That is why migrations should be backward compatible for at least one release: add columns before using them, stop using columns before dropping them, and never combine a destructive change with the code that depends on it in the same deploy.

When a rollback is impossible — a data migration that cannot be reversed — the answer is a forward fix: ship the correction quickly, with the same pipeline discipline, rather than hand-editing production.

Networking, regions and latency

Where your instances and data live determines how fast the application feels. A request that crosses an ocean to reach the database carries at least a hundred milliseconds of latency before any work happens, and no amount of application tuning removes it.

Keep the application, the database and the cache in the same region. This is the single highest-leverage latency decision, and it is free. Put static assets behind a CDN so they are served from an edge close to the user, and let only dynamic requests travel to the origin.

Do not expose the database to the public internet. Use the platform’s private network so only services in the same network can reach it, and allow access through security groups or firewall rules rather than an open port. This removes an entire class of attack and usually improves latency at the same time.

Multi-region is a step most products do not need. It multiplies cost, complicates the database story and introduces replication lag, where a user writing in one region and reading in another sees stale data. Start with one region and a CDN, measure the latency real users experience, and expand only when a specific audience demands it.

Testing a deployment before users see it

Production should not be the first place a change runs. A few layers of testing catch the failures that unit tests cannot.

Preview environments spin up a full deployment per pull request, so a reviewer can click through the actual change. Platforms that support them turn review from reading a diff into using the feature.

Staging mirrors production’s configuration — same database engine, same environment variables shape, same proxy — without production’s data. Its job is to catch the class of bug that only appears when the real components are wired together.

Smoke tests run immediately after every deploy and exercise the critical path: the health endpoint, a login, one read and one write. They are not a test suite; they are a fast confirmation that the release is alive.

#!/usr/bin/env bash
set -euo pipefail

BASE="${1:-https://app.example.com}"

curl -fsS "$BASE/healthz" >/dev/null
curl -fsS "$BASE/api/version" | grep -q '"sha"'
echo "smoke test passed"

Feature flags decouple deployment from release. The code ships dark, then a flag turns it on for one user, then a percentage, then everyone. If something is wrong, the flag flips off in seconds without a deploy at all.

Load testing before a launch tells you where the first bottleneck is: connections, CPU, or a slow query. Test with realistic concurrency and watch the database, not just the API.

Best practices

  • Start managed and move toward control only when a limitation appears.
  • Build a small multi-stage image and run it as a non-root user.
  • Inject all configuration from the environment; never bake it into the image.
  • Keep every instance stateless; put sessions, uploads and caches in managed stores.
  • Expose a cheap readiness and liveness endpoint.
  • Handle SIGTERM and close connections before exiting.
  • Run migrations as a release step, and keep them backward compatible.
  • Cap database connections per instance and pool in front of Postgres.
  • Tag releases by digest and keep the previous version ready for rollback.
  • Write structured logs to stdout and alert on user-visible symptoms.
  • Define infrastructure in version control and review plans before applying.
  • Set a budget alert and watch egress.

Common mistakes

  • Adopting Kubernetes for a single service and spending the roadmap on the cluster.
  • Storing sessions in memory, then wondering why users log out on every deploy.
  • Writing uploads to local disk and losing them when the instance is replaced.
  • Baking environment configuration into the image and rebuilding per environment.
  • Running migrations on application startup so a rolling deploy runs them many times.
  • Scaling instances without adjusting the database connection limit.
  • Trusting proxy headers blindly, or not forwarding them at all.
  • Leaving the default database connection pool size on a fleet of instances.
  • Alerting on CPU instead of on the errors and latency users feel.
  • Forgetting egress and idle resources until the first surprising invoice.
  • Having no rollback path, or one that breaks because a migration was not compatible.
  • Managing production by hand in a console with no record of what changed.

Where to go next

This guide is the destination for the artifact that CI/CD builds and promotes. The image itself comes from the Docker guide, which covers multi-stage builds and layer caching in depth. When you terminate TLS or route traffic yourself, the Nginx guide shows the proxy configuration, and the Linux guide explains the host and shell you are automating. Together they cover the path from a committed change to a running, observable release.

In practice

Package, configure, stay healthy, ship

The four artefacts that describe almost any cloud deployment.

Dockerfile
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

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

Managed platform vs self-managed servers

A managed platform trades some control and a little per-unit cost for not owning patching, TLS, failover and capacity planning.

Managed
# the platform owns the host, TLS and restarts
fly deploy
fly scale count 3
fly logs
Self-managed
# you own the OS, TLS renewal and failover
ssh prod-01
apt-get update && apt-get upgrade -y
certbot renew
systemctl restart nginx

Stateless instances vs sticky local state

Anything stored in memory or on local disk disappears on the next deploy and is invisible to the other instances behind the load balancer.

Prefer
app.post("/login", async (req, res) => {
  const sid = randomUUID();
  await redis.set(`sess:${sid}`, JSON.stringify(user), "EX", 3600);
  res.cookie("sid", sid, { httpOnly: true, secure: true });
});
Avoid
const sessions = new Map<string, User>();

app.post("/login", async (req, res) => {
  sessions.set(randomUUID(), user);
  // lost on redeploy, not shared between instances
});

Trade-offs

How much platform do you actually want?

Every layer you stop managing is a layer you no longer understand. Pick the point on the spectrum that matches your team and your traffic.

Strengths

  • Managed platforms remove toil

    TLS certificates, OS patches, log aggregation and restarts stop being tickets. A small team ships far more when it is not also running servers.

  • Containers keep you portable

    An image that runs on one container host runs on another, so moving between a PaaS and a cluster is a config change rather than a rewrite.

  • Managed data services are worth it

    Automated backups, point-in-time recovery, failover and read replicas are hard to build well. A managed Postgres is usually cheaper than the engineer who would maintain it.

Trade-offs

  • Serverless has cold starts and limits

    Functions that scale to zero are cheap when idle and slow on the first request. Long-running work, websockets and heavy CPU fit containers better.

  • Kubernetes has a real learning curve

    A cluster is a distributed system you now operate. For a single service, a PaaS is almost always the better trade until scale demands otherwise.

  • Managed convenience has a price

    Egress, per-request billing and idle resources add up quietly. Managed services cost more per unit than a server you run yourself, and the bill reflects that.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Cloud Platforms?

Our interactive tutorial walks you through Cloud Platforms step by step — with quizzes and real code you can run in the browser.