What does this solve?
The application includes a language version, a set of extensions, system libraries and a dozen environment variables. On a laptop, all this came together within a year of work; on a server, it didn’t. Docker packages the code along with the environment into a single image, and only the docker is required from the machine. The layout comes down to three steps: collect the image, put it in the registry, pick it up on the server and restart.
The second benefit is predictable rollback. The image is tagged, the old tags remain in the registry. Returning to the previous version means launching a container from a different tag, and not figuring out which files were lost during the last upload.
How it works
Image, layer, container
Dockerfile describes the assembly steps. Each instruction produces a layer—an immutable slice of the file system. All layers together are a read-only image. When launched, a thin recordable layer is placed on top of the image, and this structure with the process inside is a container.
Here is the main consequence: the container was deleted - the recording layer disappeared along with the downloaded files, logs on the disk and database data. Everything that must survive re-creation is taken out into that or into external storage.
The order of the lines in the Dockerfile decides the build time
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
Docker caches layers and reuses them as long as the step inputs do not change. The dependency manifest is copied separately and before the rest of the code - then editing one application file does not throw the dependency installation out of the cache. If you write COPY . . up to npm ci, the installation will be repeated every time any file is edited.
The base image tag is fixed explicitly. latest means that building today and building in a month will give different results with the same code.
.dockerignore
Before building, docker sends the entire directory to the daemon. Without .dockerignore they are leaving there node_modules, .git, local .env, artifacts from previous builds. The context gets inflated, the build slows down, and secrets have a chance to settle in the image layer:
.git
node_modules
.env
.env.*
dist
tmp
Multi-stage assembly
Build tools are not needed in the final image. The first stage collects, the second takes only the result:
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
The final image does not have a compiler, dev dependencies and sources - less weight, less attack surface.
Configuration and secrets
Settings are passed by environment variables at startup: --env-file, section environment in compose, secrets mechanism in the orchestrator. B ENV inside Dockerfile and in arguments RUN there is no place for secrets - team docker history shows the contents of the layers to anyone who received the image.
Ports, network, volumes
EXPOSE - documentation, does not open anything. Publishing is done at startup: -p 8080:3000 forwards the host port to the container port. Inside the compose network, services refer to each other by service name, and there is no need to publish the database port outside at all.
There are two types of volumes. A named volume is a storage space managed by Docker, where data is stored (pgdata:/var/lib/postgresql/data). Bind mount forwards the host directory inside, it is convenient for them to insert configs and view downloaded files.
compose as a minimal orchestrator
services:
app:
image: registry.example.com/app:${TAG}
env_file: .env
ports: ["127.0.0.1:8080:3000"]
depends_on: [db]
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
db:
image: postgres:16
volumes: ["pgdata:/var/lib/postgresql/data"]
restart: unless-stopped
volumes:
pgdata:
Commands for every day: docker compose up -d, docker compose logs -f app, docker compose ps, docker compose pull.
Register and display
The image is collected in CI, marked with a short commit hash and goes to the registry (Docker Hub, GitLab Registry, GHCR). There are two commands left on the server:
TAG=9f3c1a2 docker compose pull
TAG=9f3c1a2 docker compose up -d
Rollback - the same call with the previous hash. Tag latest is useless in this scheme: it is impossible to tell from it what exactly is running.
User and rights
By default, the process internally runs as root. Instructions USER changes it to an unprivileged user. Files forwarded from the host retain the owner of the host - UID discrepancy gives “permission denied” when writing to a mounted directory.
Logs
The application writes to stdout and stderr, the docker adds this to its log driver, docker logs reads. Log files inside the container are meaningless - they will disappear along with it. Rotation must be configured explicitly, otherwise the journal will clog the disk.
Useful scripts
- Mini the environment of the developer and the server: one image goes from the laptop through CI to sales.
- Place several services with incompatible versions of the language and libraries on one VPS.
- Upload dependencies for local development with one command: database, cache, broker - without installing into the system.
- Run tests in CI inside the same image that will go into production.
- Roll back to the previous tag in seconds without touching files on the server.
- Move to another hoster: the transfer comes down to copying volumes and launching the same images.
Limitations
Docker does not provide fault tolerance, backups, monitoring or certificates. Containers still need a reverse proxy with TLS (Caddy, nginx, Traefik), and volume backups are a separate task that no one will solve for you.
The processor architecture bites. The image collected on Apple Silicon meets on the amd64 server exec format error. It can be treated by assembling it for the target platform (--platform linux/amd64 or buildx), rather than trying again.
Alpine saves weight, but is built on musl instead of glibc: native modules and pre-built binaries often break or silently lose performance. For applications with native dependencies, a slim image on glibc is safer.
The condition remains a weak point. A base in a container on one machine is the same one machine, only with an additional layer on top of the disk. Containerization does not turn it into a cluster.
The disk is filling up imperceptibly: old images, intermediate layers, abandoned volumes. docker system df shows flow rate, docker system prune cleans, but removing what it needs is a matter of one sloppy flag.
Access to the docker socket is equal to root rights on the host. Forward /var/run/docker.sock into a container for the sake of convenience - to open the whole car.
Debugging inside a slim image is inconvenient: there is neither an editor nor the usual utilities. Putting them in a product image for the sake of a one-time diagnosis is a bad exchange.
Compose ceiling - one machine. Then Swarm, Kubernetes, Nomad begin, and this is a different class of complexity that pays off on more than three services.
Docker does not solve database migrations and version compatibility at all. Rolling back an image does not roll back the schema.
How to check the result
Build and run locally to any server:
docker build -t app:test .
docker run --rm -p 8080:3000 --env-file .env.local app:test
curl -i localhost:8080/health
See what got into the layers: docker history app:test shows commands and step weights. The secret hidden in the argument RUN, visible here. The context size is printed on the first line of the output docker build —a figure of hundreds of megabytes means forgotten .dockerignore.
Verify user: docker run --rm app:test id should not show uid=0(root).
Check ephemerality honestly: upload the file through the application, execute docker compose down && docker compose up -d, see if it is there. Lost - it means that the data is written to the recording layer, and not to that one.
Check that exactly what is collected is running on the server. The image digest locally and in the registry must match:
docker image inspect --format '{{index .RepoDigests 0}}' registry.example.com/app:9f3c1a2
Check the config after substituting variables: docker compose config prints the final version with all values - here you can see empty variables, which otherwise would appear as a crash at startup.
Health: docker ps shows healthcheck status. Container is in condition unhealthy in a live process means that the check is written without a real sign of operability.
One line rule: only code with dependencies is put into the image, everything that survives the re-creation of the container goes into that volume, and everything that depends on the environment goes into environment variables.
