Beyond your code: environments, Docker, and the server as just another machine
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:
| Layer | In this project | What goes wrong when it differs |
|---|---|---|
| Runtime | Node 24.13.0, pinned in .nvmrc | A colleague on Node 20 hits APIs that don't exist yet |
| Packages | node_modules, resolved from pnpm-lock.yaml | Someone runs npm install, skips the lockfile, and gets newer versions that behave differently |
| Services | Postgres 18, Redis, Keycloak | No Redis, no background jobs. Wrong Postgres major, migrations may fail |
| Config | .env: URLs, ports, secrets | Right code, wrong database |
| OS | macOS on my laptop, Linux on the server | File 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:
| Layer | How Docker pins it |
|---|---|
| Runtime | The Dockerfile starts FROM node:24-alpine, so Node is part of the image |
| Packages | pnpm install runs while the image is built, so node_modules is in the image too, built on Linux |
| Services | Each one runs as its own container, from an image pulled from a registry |
| Config | The compose file passes variables into each container, filled in from .env |
| OS | Every 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 want | Terminal | Docker Desktop |
|---|---|---|
| Create and start the project | docker compose up -d | — |
| Start or stop it again | docker compose start / stop | ▶ / ■ on the project row |
| See what's running, and if it's healthy | docker compose ps | Containers list |
| Follow one service's logs | docker compose logs -f api | container → Logs |
| Open a shell inside a container | docker compose exec postgres sh | container → Exec |
| Restart one service | docker compose restart api | Restart on the container |
| Remove the project's containers | docker compose down | Delete on the project row |
| List images and volumes | docker images, docker volume ls | Images, 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 atpostgres.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.envfile next to the compose file, falling back toapp.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/postgresqlmounts the named volume declared at the bottom, which is where the data lives../keycloak/realm.json:…:rois 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_isreadyreports when it does, and the app services wait for that withdepends_on: condition: service_healthy(below) — which is why a colddocker compose upcomes 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:
- Infrastructure in Docker, apps on your Mac:
docker compose up -d postgres keycloak redis, thenpnpm dev. Faster reloads, and a debugger that just attaches. - 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 client | Read DATABASE_URL and friends |
| Bundle the web app | Apply 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.yml | docker-compose.staging.yml | |
|---|---|---|
| Keycloak | Own container, realm imported from the repo | None. The company's Keycloak, at auth.example.com |
| Code | Bind-mounted source, target: dev | Baked into the image, target: runner |
| Ports | Everything 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 |
| Web | Vite dev server | nginx 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:
- What services are there? Each one is a container and a hostname.
- Which ports reach the host? Those are the only ones you can open from your browser or your tools.
- Where does config come from?
environment,${…}defaults,.env,--env-file. - Where does data live? Named volumes and bind-mounted folders survive
down. Anything written inside the container doesn't. - What does each container actually run?
CMDin the Dockerfile,commandin the compose file, and the entrypoint underneath. - 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.