← Back to blog

Deploying with Docker and Docker Compose on a VPS

Md Asaduzzaman · · 5 min read

DevOpsDockerNode.jsReactDeployment

In the previous post we installed Node.js directly on the server. That works, but every server needs the same Node version, the same global tools and the same setup steps. With Docker, the app and everything it needs travel together in an image. If it runs on your laptop, it runs on the server.

We will deploy the same project: a React frontend, a Node.js API and a PostgreSQL database.

code
myapp/
├── client/          # React (Vite)
│   ├── Dockerfile
│   └── nginx.conf
├── server/          # Node.js / Express API
│   └── Dockerfile
├── docker-compose.yml
└── .env             # secrets, never committed

1. Install Docker on the VPS

Use Docker's official script (or follow the apt steps on docs.docker.com):

bash
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

Log out and back in so the group change applies, then check:

bash
docker --version
docker compose version
docker run --rm hello-world

Firewall warning: Docker writes its own iptables rules, so a port published with -p 5432:5432 is open to the internet even if UFW blocks it. Only publish what must be public, or bind to localhost like 127.0.0.1:5432:5432.

2. Dockerfile for the Node.js API

dockerfile
# server/Dockerfile
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production

# Copy package files first so this layer is cached between builds
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

# Don't run as root inside the container
USER node
EXPOSE 5000
CMD ["node", "server.js"]

Inside a container, the API must listen on 0.0.0.0, not 127.0.0.1, or other containers can't reach it:

js
app.listen(5000, '0.0.0.0');

3. Multi-stage Dockerfile for the React app

The first stage builds the app with Node. The second stage copies only the built files into a tiny Nginx image, so the final image has no Node.js and no node_modules:

dockerfile
# client/Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build

FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
nginx
# client/nginx.conf
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://api:5000;   # "api" is the Compose service name
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

4. A .dockerignore for each app

Without it, Docker sends your whole node_modules folder and your secrets into the build:

text
node_modules
dist
build
.git
.env
*.log

5. Docker Compose: all services in one file

code
# docker-compose.yml
services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      retries: 5

  api:
    build: ./server
    restart: unless-stopped
    env_file: .env
    depends_on:
      db:
        condition: service_healthy

  web:
    build: ./client
    restart: unless-stopped
    depends_on:
      - api
    ports:
      - "127.0.0.1:8080:80"

volumes:
  pgdata:

Things to notice:

  • Only web publishes a port, and only on localhost. The database and API are reachable only inside Docker's private network, by their service names (db, api).
  • The pgdata volume keeps your database when containers are rebuilt. Without it, every deploy would wipe your data.
  • restart: unless-stopped brings everything back after a crash or a reboot.
  • depends_on with a healthcheck makes the API wait until Postgres is actually ready.

The .env file on the server:

ini
POSTGRES_PASSWORD=change-me-to-a-long-random-string
DATABASE_URL=postgresql://postgres:change-me-to-a-long-random-string@db:5432/postgres
JWT_SECRET=another-long-random-string

Generate strong values with openssl rand -hex 32.

6. Start it

bash
cd ~/myapp
docker compose up -d --build
docker compose ps
docker compose logs -f api

curl http://127.0.0.1:8080/api/health on the server should answer.

7. Nginx and HTTPS in front

The container listens on 127.0.0.1:8080. Use the Nginx you installed on the host (from the previous post) as the public entry point with Let's Encrypt:

nginx
server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;
    }
}
bash
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com -d www.example.com

Prefer to keep everything in Docker? Caddy as a Compose service gets HTTPS certificates automatically with a two-line config. Both approaches are fine.

8. Deploying updates

bash
cd ~/myapp
git pull --ff-only
docker compose up -d --build
docker image prune -f      # remove old images so the disk doesn't fill up

Compose only recreates containers whose image or config changed, and the database volume is untouched.

Even better: build images in GitHub Actions, push them to GitHub Container Registry (ghcr.io), and on the server just run docker compose pull && docker compose up -d. A small VPS then never has to run a heavy build.

9. Backups

Your code is in Git, but your data is only in the volume. Back it up:

bash
docker compose exec -T db pg_dump -U postgres postgres | gzip > backup-$(date +%F).sql.gz

Run it from a daily cron job and copy the files off the server (to S3, Backblaze or another machine). A backup that lives only on the same VPS is not a backup.

Useful commands

CommandWhat it does
docker compose psStatus of all services
docker compose logs -f webFollow one service's logs
docker compose exec api shOpen a shell inside a container
docker compose restart apiRestart one service
docker compose downStop everything (volumes are kept)
docker system dfSee how much disk Docker is using

Node.js on the host or Docker?

Node.js + PM2Docker Compose
SetupQuick, few moving partsA bit more to learn
Same environment everywhereNoYes
Several apps / databases on one VPSGets messyClean, each in its own container
Memory useLowestA little higher
Moving to another serverRepeat every stepCopy the repo and .env, run one command

For a single small app, PM2 is perfectly fine. Once you have a database, a few services or more than one project on the same server, Docker Compose keeps everything tidy and repeatable.

© ALL RIGHT RESERVED ASADUZZAMAN