A typical full-stack project has three parts: a front end (React, Vue, Svelte or similar), an API (Node, Python, Go, PHP…) and a database. On one VPS you can run all three reliably with Docker Compose behind Nginx, and deploy every push automatically.
The target setup
https://example.com → Nginx → static front-end files
https://example.com/api/ → Nginx → API container (127.0.0.1:4000)
└─ PostgreSQL container (internal network only)
1. Prepare the server
Follow a basic hardening checklist first (Ubuntu or Debian), then install Docker and Nginx:
curl -fsSL https://get.docker.com | sh
apt install -y nginx certbot python3-certbot-nginx
adduser deploy && usermod -aG docker deploy
mkdir -p /srv/app && chown deploy: /srv/app
2. Containerize the API
api/Dockerfile (Node example):
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 4000
CMD ["node", "server.js"]
3. Compose file on the server
/srv/app/compose.yaml:
services:
api:
image: ghcr.io/you/app-api:${TAG:-latest}
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:4000:4000"
depends_on: [db]
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
/srv/app/.env (permissions 600, never committed):
DB_PASSWORD=change-me
DATABASE_URL=postgres://app:change-me@db:5432/app
JWT_SECRET=…
The database has no published port — only the API container can reach it.
4. Nginx: static front end + API
Build the front end into static files (npm run build → dist/) and serve them from /srv/app/web:
server {
server_name example.com;
root /srv/app/web;
location /api/ {
proxy_pass http://127.0.0.1:4000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri /index.html; # single-page app routing
}
}
certbot --nginx -d example.com --redirect
5. Automate deploys with GitHub Actions
.github/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- name: Build and push API image
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker build -t ghcr.io/${{ github.repository }}-api:${{ github.sha }} api
docker push ghcr.io/${{ github.repository }}-api:${{ github.sha }}
- name: Build front end
run: cd web && npm ci && npm run build
- name: Upload front end
uses: appleboy/[email protected]
with:
host: ${{ secrets.HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
source: web/dist/*
target: /srv/app/web
strip_components: 2
- name: Restart API
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: |
cd /srv/app
export TAG=${{ github.sha }}
docker compose pull api
docker compose run --rm api npm run migrate
docker compose up -d api
docker image prune -f
(Pin action versions to ones you’ve reviewed; the image name must be lowercase.)
6. Rollback
Because images are tagged by commit, rolling back is one command:
cd /srv/app && TAG=<previous-commit-sha> docker compose up -d api
Keep database migrations backward-compatible (add columns before removing old ones) so rollbacks don’t break.
7. Backups and monitoring
- Dump PostgreSQL nightly (
docker compose exec db pg_dump -U app app) and ship it off-server — see backup to cloud storage. - Add uptime checks for
/and/api/health.
Summary
Containers for the API and database, Nginx for TLS and static files, environment variables outside Git, and a CI job that builds, migrates and restarts. A 2–4 GB Docker VPS runs this comfortably for most projects. Related: Nginx reverse proxy for multiple containers and PostgreSQL on a VPS.