Skip to content
DevOps projects 6 min read

Project: Dockerize & Deploy an App

In this project you take a small web app, package it into a container (a lightweight, self-contained box holding your app and everything it needs to run), add a database next to it, run the whole thing on your laptop, and then deploy it on a real Ubuntu server behind Nginx with HTTPS. Containers matter because they kill the classic “it works on my machine” problem: the exact same image runs identically on your laptop and on the server. By the end you will have a repeatable, production-style deployment that you can rebuild from scratch in minutes.

This is the container-deployment capstone. It pulls together the Docker and Compose concepts from the containers section into one end-to-end workflow.

What you will build

A two-container stack:

  • app — a Node.js web app (any language works; the pattern is identical).
  • db — a PostgreSQL database with a volume (a folder on the host that survives container restarts so your data is not lost).

In front of both, on the server, sits Nginx acting as a reverse proxy (a server that sits in front of your app and forwards browser requests to it), terminating TLS (the encryption behind HTTPS).

Prerequisites

On both your laptop and the Ubuntu 22.04/24.04 LTS server, install Docker Engine and the Compose plugin:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER

Log out and back in so your user joins the docker group, then verify:

docker version
docker compose version

Output:

Docker version 27.3.1, build ce12230
Docker Compose version v2.29.7

Step 1 — Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to build your app’s image (the read-only template a container is started from). Create Dockerfile in your app folder:

# Use a small, pinned base image — never use :latest in production
FROM node:20-bookworm-slim

# Run as a non-root user for safety
WORKDIR /app

# Copy only manifests first so Docker can cache the npm install layer
COPY package*.json ./
RUN npm ci --omit=dev

# Now copy the rest of the source
COPY . .

ENV NODE_ENV=production
EXPOSE 3000

# The command that starts your app
CMD ["node", "server.js"]

Tip: Copy package*.json and install dependencies before copying your source code. Docker caches each step as a layer, so this way a code change does not force a slow re-install of every dependency.

Add a .dockerignore so junk never lands in your image:

node_modules
npm-debug.log
.git
.env

Build and test it locally:

docker build -t myapp:1.0 .

Output:

[+] Building 12.4s (11/11) FINISHED
 => => naming to docker.io/library/myapp:1.0

Step 2 — Add a database with docker-compose.yml

Running one container by hand is fine, but real apps need a database too. Docker Compose lets you describe several containers in one docker-compose.yml file and start them together. Create it next to your Dockerfile:

services:
  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:3000:3000"   # only expose to localhost; Nginx will face the internet

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume = data survives restarts
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  db-data:

Two things to notice. The app reaches the database at the hostname db (Compose creates a private network where each service is reachable by its name). And the db-data named volume stores Postgres files outside the container, so destroying and recreating the container keeps your data.

Put the database password in a .env file beside the Compose file (Compose reads it automatically). Never commit this file:

echo "DB_PASSWORD=$(openssl rand -base64 24)" > .env
chmod 600 .env

Step 3 — Run it locally

docker compose up -d --build
docker compose ps

Output:

NAME            IMAGE         STATUS                   PORTS
myapp-app-1     myapp         Up 8 seconds             127.0.0.1:3000->3000/tcp
myapp-db-1      postgres:16   Up 9 seconds (healthy)   5432/tcp

Visit http://localhost:3000 to confirm it works. Tail the logs if anything looks off:

docker compose logs -f app

When done locally, docker compose down stops everything (add -v to also delete the volume and its data).

Step 4 — Deploy on the server behind Nginx with TLS

Copy your project to the server (for example with git clone or scp), create the .env there, and start the stack:

docker compose up -d --build

Because the app only binds to 127.0.0.1:3000, the internet cannot reach it directly — that is exactly what we want. Nginx will be the public front door. Install it and add a site config:

sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name myapp.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Enable the site, test the config, and reload:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Output:

nginx: configuration file /etc/nginx/nginx.conf test is successful

Open the firewall and add free TLS certificates with Certbot. Certbot edits your Nginx config to serve HTTPS and auto-renews via a systemd timer:

sudo ufw allow 'Nginx Full'
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com

Your app is now live at https://myapp.example.com. To ship a new version, pull the code and rebuild:

git pull
docker compose up -d --build

Bind-mount vs named volume — which to use

OptionWhat it isWhen to use
Named volume (db-data:)Docker-managed storage on the hostDatabases and app data in production — portable and backed up easily
Bind mount (./src:/app)A specific host folder mapped inLocal development for live code reload — not for production data

Security gotcha: Never publish your database port (5432) to the public internet in the Compose file. Keep it on the internal Compose network only, as shown above. An exposed Postgres with a weak password is scanned and breached within minutes.

Best Practices

  • Pin image tags (postgres:16, node:20-bookworm-slim) — never rely on :latest, which changes under you.
  • Bind app ports to 127.0.0.1 and let Nginx be the only thing facing the internet.
  • Keep secrets in a .env file with chmod 600, and add it to .gitignore and .dockerignore.
  • Use restart: unless-stopped so containers survive reboots and crashes.
  • Add a healthcheck and depends_on: condition: service_healthy so the app waits for the database to be ready.
  • Store data in named volumes and back them up (docker run --rm -v db-data:/data ...) — containers are disposable, volumes are not.
  • Run docker compose logs and docker system prune regularly to debug and to reclaim disk space.
Last updated June 15, 2026
Was this helpful?