Backend

Deploying a Project with Docker

The phrase “it worked on my machine” nearly disappears with Docker, because the app is packaged with all its dependencies into one fixed image. This guide walks through Docker deployment from the Dockerfile to a safe run on a server.

  • By TETA Team
  • 8 min read
  • Updated:
Deploying a Project with Docker

Why use Docker for deployment

Without containers, deploying means manually installing the right language version, libraries and settings on every server, and small differences between servers create bugs that take days to find. With Docker, the exact image that passed testing goes to production unchanged.

  • Repeatable: laptop, staging and production environments match.
  • Fast rollback: if a release is bad, run the previous image tag again.
  • Isolation: several apps with different versions share one server without conflict.

Writing a multi-stage Dockerfile

In a multi-stage build, the first stage holds the build tools (compiler, devDependencies) and the final stage contains only the output. Example for a Node.js app:

dockerfile
# ---- build stage ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime stage ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
  • We copy and install only package*.json first so the dependency layer stays cached until those files change, keeping builds fast.
  • USER node runs the app as a non-root user, limiting the damage if it is compromised.
  • Create a .dockerignore listing node_modules, .git and .env files so they never enter the image.

Every instruction is documented in the Dockerfile reference.

Defining the whole stack with docker compose

A real application usually has several parts: the web app, a database and a cache. A compose file brings them all up in one place with one command:

yaml
services:
  app:
    image: registry.example.com/shop-api:1.4.2
    restart: unless-stopped
    env_file: .env
    ports: ["127.0.0.1:3000:3000"]
    depends_on:
      db: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 15s
      retries: 3
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_pass
    volumes: [dbdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
  cache:
    image: redis:7-alpine
    restart: unless-stopped
volumes:
  dbdata:

A few notes: the app port is published only on 127.0.0.1 so it is not directly reachable from outside and traffic goes through Nginx; the database and Redis publish no ports at all and are visible only on the internal compose network. A specific version tag (1.4.2) instead of latest makes rollback predictable. For Redis usage patterns see smart caching with Redis.

Colourful abstract illustration of cube-shaped containers stacked on a server and linked by glowing lines
One compose file makes the whole stack repeatable: the same configuration on a laptop, staging and production.

Configuration, secrets and persistent data

Twelve-factor principle: configuration comes from the environment, not from code. Keep the .env file only on the server and never in Git or an image. For more sensitive secrets use Docker secrets or a cloud secret manager and read them from a file path in the app.

Containers are ephemeral; whenever one is recreated, files inside it vanish. Put anything that must survive (database files, uploads) on a volume and back it up. A backup whose restore you have never tested is not a backup.

bash
# nightly logical backup of the Postgres volume's data
docker compose exec -T db pg_dump -U postgres shop | gzip > /backups/shop-$(date +%F).sql.gz

Releasing to the server and updating

A simple, reliable flow for a single server:

  1. In CI, build the image with a version tag and push it to a registry (Docker Hub, GitHub Container Registry or a private one).
  2. On the server, set the new tag in docker-compose.yml (or change the version variable).
  3. docker compose pull then docker compose up -d recreates only the changed services.
  4. Check the health check and, if it fails, restore the previous tag and run up -d again.
bash
docker compose pull app
docker compose up -d app
docker compose ps          # STATUS should say "healthy"
docker compose logs -f --tail=50 app
docker image prune -f      # reclaim disk from old layers

With a single service there may be a few seconds of downtime during the swap. If you need zero downtime, run two instances behind Nginx and update them one at a time, or use an orchestrator. You can also wire automated CI deployment to the tests described in automated testing in Laravel so only healthy builds ship.

Production checklist and observability

  • Small base image (alpine or slim) with a pinned tag, not latest.
  • Run as non-root, with a read-only filesystem where possible.
  • restart: unless-stopped and a health check for every service.
  • Memory and CPU limits so one service cannot swallow the whole server.
  • Log rotation enabled (max-size and max-file on the json-file driver) so the disk does not fill up.
  • Automated backups and periodic restore tests.

After deployment you cannot tell something broke without monitoring; for logs, metrics and alerts see professional monitoring and logging. If you would rather hand infrastructure and deployment to an experienced team, TETA Team web services cover that too.

Frequently asked questions

What is the difference between Docker and docker compose?

Docker builds and runs a single container. docker compose defines several related containers, their networks and volumes in one YAML file and starts them with one command, which suits multi-part applications.

Do I need Docker for a small website?

Not strictly, but it makes deployment repeatable and reversible and simplifies rebuilding on a new server. For a project that will grow, the learning cost usually pays off.

Is database data lost in Docker?

Only if it is not on a volume. With a named volume the data survives container removal and recreation. Still, always take separate backups.

How do I make a Docker image smaller?

Use an alpine or slim base, multi-stage builds, install only production dependencies and add a .dockerignore file. This typically drops size from hundreds of megabytes to tens.

Comments

    No comments yet. Be the first to share your thoughts.

    Comments are published after review.