Le Quang Lam
← All writing

Beyond your code: environments, Docker, and the server as just another machine

October 17, 2026·13 min read·software

When I joined my current project, the repo came with two Dockerfiles and two compose files, one for development and one for staging. I followed the README, ran docker compose up -d --build, and from then on starting work meant clicking ▶ in Docker Desktop. It worked, but I couldn't have told you what I had started, where the database lived, or why the API could find it. This post is me going through those files until every part of them has a reason.

Your code is the smallest part

The code in a repo can't run by itself. It needs a whole environment around it, and every layer of that environment can be different on someone else's machine:

LayerIn this projectWhat goes wrong when it differs
RuntimeNode 24.13.0, pinned in .nvmrcA colleague on Node 20 hits APIs that don't exist yet
Packagesnode_modules, resolved from pnpm-lock.yamlSomeone runs npm install, skips the lockfile, and gets newer versions that behave differently
ServicesPostgres 18, Redis, KeycloakNo Redis, no background jobs. Wrong Postgres major, migrations may fail
Config.env: URLs, ports, secretsRight code, wrong database
OSmacOS on my laptop, Linux on the serverFile paths, file watching, which binaries run

"Works on my machine" is what you say when one of those rows differs and you don't know which.

What Docker packages

The first table had five rows. Docker has an answer for each one, and most of its terms come from those answers:

LayerHow Docker pins it
RuntimeThe Dockerfile starts FROM node:24-alpine, so Node is part of the image
Packagespnpm install runs while the image is built, so node_modules is in the image too, built on Linux
ServicesEach one runs as its own container, from an image pulled from a registry
ConfigThe compose file passes variables into each container, filled in from .env
OSEvery container runs on Linux. On a Mac, Docker Desktop runs a small Linux VM for them

So an image is the runtime, the packages, and the code frozen together, and a container is that image running. A Dockerfile is the recipe an image is built from. The API and the web app are your code, so their Dockerfiles are yours to write. Postgres, Redis, and Keycloak aren't, so their maintainers have already written the Dockerfiles, built the images, and published them to a registry, a store of images. You just pull them: postgres:18.3 from Docker Hub and quay.io/keycloak/keycloak:26.4 from Quay.

One thing isn't in the table, because it isn't environment: the data. Everything above should be identical on every machine and safe to rebuild. A database's rows are the opposite. They're yours, they change, and they have to survive. So Docker keeps them out of the container, in a volume:

The container gets deleted and recreated, with a new id. The volume stays — until you ask for it to be deleted.

Every button is a command

Docker Engine is the thing that actually builds images and runs containers. Docker Desktop and the docker CLI are both just clients that ask it to do things — so every button in Desktop has a command behind it.

What you wantTerminalDocker Desktop
Create and start the projectdocker compose up -d—
Start or stop it againdocker compose start / stop▶ / ■ on the project row
See what's running, and if it's healthydocker compose psContainers list
Follow one service's logsdocker compose logs -f apicontainer → Logs
Open a shell inside a containerdocker compose exec postgres shcontainer → Exec
Restart one servicedocker compose restart apiRestart on the container
Remove the project's containersdocker compose downDelete on the project row
List images and volumesdocker images, docker volume lsImages, Volumes

Reading docker-compose.yml

This is the development compose file from the project, trimmed to the parts worth explaining:

name: app

services:
  postgres:
    image: postgres:18.3
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-app}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app_password}
      POSTGRES_DB: ${POSTGRES_DB:-app}
    ports:
      - '${POSTGRES_PORT:-5433}:5432'
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-app}']
      interval: 5s
      retries: 20

  redis:
    image: redis:8-alpine
    ports:
      - '${REDIS_PORT:-6379}:6379'
    volumes:
      - redisdata:/data

  keycloak:
    image: quay.io/keycloak/keycloak:26.4
    command: ['start-dev', '--import-realm']
    ports:
      - '${KEYCLOAK_PORT:-8080}:8080'
    volumes:
      - ./keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro

  # api, worker, web: below

volumes:
  pgdata:
  redisdata:

Going through it:

  • services — each key becomes one container, and the key is also its hostname on the project's network. Other containers reach this database at postgres.
  • image — what to pull. Postgres is pinned to an exact version, 18.3. Redis is pinned only to its major version, 8.
  • environment — variables passed into the container. ${POSTGRES_USER:-app} is filled in by Compose before the container starts, from your shell or the .env file next to the compose file, falling back to app.
  • ports: '5433:5432' — host:container. A container has its own ports, separate from your Mac's. Postgres listens on 5432 inside it, so your Mac can't reach it until Compose opens port 5433 on your Mac and forwards it there. Why not 5432 on both sides? Only one program can hold a port, and if you already have Postgres installed, it's holding 5432 on your Mac.
  • volumes — two kinds. pgdata:/var/lib/postgresql mounts the named volume declared at the bottom, which is where the data lives. ./keycloak/realm.json:…:ro is a bind mount: a file from the repo shown to the container, read-only.
  • healthcheck — a command Docker runs repeatedly to decide whether the container is actually ready, not just started. Postgres takes a few seconds to accept connections. pg_isready reports when it does, and the app services wait for that with depends_on: condition: service_healthy (below) — which is why a cold docker compose up comes up working instead of crashing on a database that isn't listening yet.
  • restart: unless-stopped — if the process crashes, Docker starts it again. It stays stopped only if you stopped it.

Infrastructure in Docker, apps on your Mac

The file can run everything, but my day-to-day is half of it:

docker compose up -d postgres keycloak redis
pnpm dev          # api + web, on the host
pnpm dev:worker   # the job worker, in a second terminal

Naming services after up starts only those, so there are two ways to run the project:

  1. Infrastructure in Docker, apps on your Mac: docker compose up -d postgres keycloak redis, then pnpm dev. Faster reloads, and a debugger that just attaches.
  2. Everything in Docker: docker compose up -d. The source code is bind-mounted, so the apps still hot-reload.

Which one you pick changes how the apps find the services. That turned out to be the most important thing in this whole post.

localhost vs a service name

On the host, the API reads .env:

DATABASE_URL=postgresql://app:app_password@localhost:5433/app
REDIS_URL=redis://localhost:6379

Inside Compose, the same API gets:

  api:
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://app:app_password@postgres:5432/app
      REDIS_URL: redis://redis:6379

From the host, Postgres is localhost:5433 — the published port. On a Mac, the containers live inside Docker Desktop's Linux VM, so a connection to that port takes two hops: Docker Desktop holds 5433 on your Mac and passes the connection into the VM, where Docker Engine forwards it to the container's 5432. A Linux server has no VM, so it's one hop. From inside the network, it's postgres:5432 — the service name and the port it really listens on. The published port doesn't matter in there.

When the apps run in Compose, there's one more pair of lines worth noticing:

    volumes:
      - .:/app
      - /app/node_modules

The first bind-mounts the repo into the container, which is what makes hot reload work. But that would also replace the container's node_modules with the one on my Mac, built for macOS. The second line puts an anonymous volume over that one path. The container keeps its own Linux-built packages. That's the "Packages" row from the first table, showing up in YAML.

Reading the Dockerfile

The API's Dockerfile, the recipe for its image, is one file with several stages. Each FROM … AS name starts a new one, and a stage can start from an earlier one:

FROM node:24-alpine AS base
RUN corepack enable
WORKDIR /app

FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
# …one line per workspace package.json
RUN pnpm install --frozen-lockfile

FROM deps AS dev
COPY . .
CMD ["pnpm", "-F", "@app/api", "dev"]

FROM deps AS build
COPY . .
RUN pnpm -F @app/api db:generate && pnpm -F @app/api build

FROM base AS runner
ENV NODE_ENV=production
COPY --from=build /app /app
COPY apps/api/docker-entrypoint.sh /usr/local/bin/api-entrypoint.sh
RUN chmod +x /usr/local/bin/api-entrypoint.sh
EXPOSE 3001
HEALTHCHECK CMD wget -q --spider http://127.0.0.1:3001/health || exit 1
CMD ["api-entrypoint.sh"]

A compose file picks which stage to stop at with target. Here's the API in both files:

# docker-compose.yml
  api:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: dev
    volumes:
      - .:/app
      - /app/node_modules
# docker-compose.staging.yml
  api:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: runner

Development stops at dev. That stage copies the source too, but the bind mount covers it at run time — what dev really needs from the image is the Linux-built node_modules. Staging goes all the way to runner: compiled code, no bind mount, and api-entrypoint.sh running on start.

The order inside deps is deliberate. Docker caches each step as a layer and reuses it as long as the files that step copied haven't changed. Once one step changes, every step after it runs again. Copying only the package.json files and the lockfile first means pnpm install is skipped on every build where only source code changed — which is nearly all of them. A single COPY . . up front would rerun it on every edit.

Build time vs run time

This was the distinction I was missing. Some things happen once, when the image is built. Others happen every time a container starts. In the Dockerfile above, every RUN is the first kind, and CMD names the second:

At build (docker compose build)At start (docker compose up)
Install packages, compile TypeScript, generate the Prisma clientRead DATABASE_URL and friends
Bundle the web appApply database migrations
Start the server

Migrations are in the second column because they need a live database, and there's no database while an image is being built. So they live in docker-entrypoint.sh:

#!/bin/sh
set -eu
pnpm -F @app/api db:migrate
exec node /app/apps/api/dist/server.js

The web app goes the other way. Vite copies every VITE_ variable into the JavaScript bundle at build time, so VITE_API_BASE_URL is fixed in the image. Changing the public URL means up -d --build; a restart changes nothing. And the web image that gets deployed doesn't contain Node at all — it's nginx serving the built files.

Last piece: .dockerignore lists what never gets sent to the build — node_modules, dist, .git, and .env. Without it, COPY . . would copy your local secrets into the image.

One image, two containers: the worker

The worker runs scheduled jobs, like a nightly cleanup. The API puts a job into a queue stored in Redis, and the worker takes it out and runs it. That's Redis's real job in this project. It isn't a cache here.

The worker needs the same code and the same dependencies as the API, so on staging it doesn't get an image of its own. In docker-compose.staging.yml, the worker runs the API's image with a different command:

  api:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: runner
    image: app-staging-api

  worker:
    image: app-staging-api
    pull_policy: never
    command: ['node', '/app/apps/api/dist/worker.js']
    healthcheck:
      disable: true

One image, two containers, two processes. Building the API updates both, and pull_policy: never stops Compose from looking for app-staging-api on Docker Hub, since it only exists on this machine. command replaces the image's CMD, so the worker skips the migrate-then-serve script. Migrations stay with the API, and the two never race each other over the schema.

The healthcheck is disabled because the one baked into the image checks the API's port, which the worker never opens. (The development file still builds the worker separately from the dev target, so it hot-reloads like the API.)

The server is just another machine

Everything so far happened on a laptop. Here are the same six services in the two local setups from earlier, and then on staging:

The services stay where they are. What changes are the boundaries around them, and with those, the addresses. Staging has its own compose file, docker-compose.staging.yml, and put next to the development one, the differences are short:

docker-compose.ymldocker-compose.staging.yml
KeycloakOwn container, realm imported from the repoNone. The company's Keycloak, at auth.example.com
CodeBind-mounted source, target: devBaked into the image, target: runner
PortsEverything published on the host (all interfaces)API on 127.0.0.1 only; Postgres and Redis not published at all
Env vars${X:-default}: fall back to a dev value${X:?set X}: refuse to start without it
WebVite dev servernginx serving a static build

Each change is a decision about the new environment. Postgres stops being reachable from outside, missing secrets become an error instead of a default, and the code stops being live-edited. Nothing new has to be learned to read it.

Deploying over SSH

Deploying by hand is the same commands, typed on a different machine:

ssh me@my-server
cd ~/app
git pull
docker compose -f docker-compose.staging.yml --env-file .env.staging up -d --build
docker compose -f docker-compose.staging.yml --env-file .env.staging logs -f api

-f picks the staging file. --build rebuilds the images from the new code. up -d replaces only the containers whose image or config changed. On start, the API's entrypoint applies any new migrations by itself.

A one-off task is an exec into the running container. Seeding reference data after the first deploy, for example:

docker compose -f docker-compose.staging.yml --env-file .env.staging \
  exec api pnpm -F @app/api db:seed:reference

A checklist for a Docker project you've never seen

The next time I open someone else's compose file, these are the questions, in order:

  1. What services are there? Each one is a container and a hostname.
  2. Which ports reach the host? Those are the only ones you can open from your browser or your tools.
  3. Where does config come from? environment, ${…} defaults, .env, --env-file.
  4. Where does data live? Named volumes and bind-mounted folders survive down. Anything written inside the container doesn't.
  5. What does each container actually run? CMD in the Dockerfile, command in the compose file, and the entrypoint underneath.
  6. Is there a separate compose file for deploys? Read it next to the dev one. The differences are the deploy.

The server is the same set of answers, on a machine that isn't mine.

References