Deploying with Docker and Docker Compose on a VPS
Md Asaduzzaman · · 5 min read
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.
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):
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:
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:5432is open to the internet even if UFW blocks it. Only publish what must be public, or bind to localhost like127.0.0.1:5432:5432.
2. Dockerfile for the Node.js API
# 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:
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:
# 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
# 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:
node_modules
dist
build
.git
.env
*.log
5. Docker Compose: all services in one file
# 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
webpublishes 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
pgdatavolume keeps your database when containers are rebuilt. Without it, every deploy would wipe your data. restart: unless-stoppedbrings everything back after a crash or a reboot.depends_onwith a healthcheck makes the API wait until Postgres is actually ready.
The .env file on the server:
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
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:
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;
}
}
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
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:
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
| Command | What it does |
|---|---|
docker compose ps | Status of all services |
docker compose logs -f web | Follow one service's logs |
docker compose exec api sh | Open a shell inside a container |
docker compose restart api | Restart one service |
docker compose down | Stop everything (volumes are kept) |
docker system df | See how much disk Docker is using |
Node.js on the host or Docker?
| Node.js + PM2 | Docker Compose | |
|---|---|---|
| Setup | Quick, few moving parts | A bit more to learn |
| Same environment everywhere | No | Yes |
| Several apps / databases on one VPS | Gets messy | Clean, each in its own container |
| Memory use | Lowest | A little higher |
| Moving to another server | Repeat every step | Copy 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.