Host Multiple Apps on One VPS with Docker
Summary
Pixel shares a practical guide to hosting multiple client apps on one Ubuntu VPS with Docker Compose, host-level Nginx, shared database containers, and GitHub Actions deployments. It explains how to isolate production and development apps, keep services private behind localhost ports, and simplify affordable hosting without the overhead of Kubernetes.
DEV Community
Hosting Several Client Apps on One VPS with Docker, Nginx and GitHub Actions
Most small agencies and freelancers end up in the same place: a pile of small client projects. A Next.js marketing site here, a Laravel admin panel there, a Node API for a mobile app, a couple of staging copies. Paying for a separate managed platform for each one adds up fast, and a Kubernetes cluster is far more machinery than a handful of small apps need. This post walks through the setup we use to run several small apps on one VPS: a Docker Compose file per project, Nginx on the host as the reverse proxy, one database container per engine, and GitHub Actions to deploy on every push. Nothing clever. Boring is the point, because boring is easy to debug at 11 pm. The shape of the server Host: Ubuntu LTS with Docker Engine, the Compose plugin, Nginx and Certbot installed directly on the host. Apps: every project lives in /srv/apps/// with its own docker-compose.yml and .env. Ports: each app container binds to 127.0.0.1: only, so nothing is exposed to the internet except Nginx on 80/443. Databases: one MySQL, one PostgreSQL and one MongoDB container on a private Docker network. Each engine holds both the prod and dev databases, with a separate user per database. /srv
├── infra/
│ ├── databases/docker-compose.yml
│ ├── nginx-templates/site.conf
│ └── ports.txt
└── apps/
├── shop/
│ ├── prod/docker-compose.yml
│ └── dev/docker-compose.yml
└── school-crm/
├── prod/docker-compose.yml
└── dev/docker-compose.yml
Why Nginx on the host instead of Traefik or a self-hosted PaaS? Fewer moving parts, less RAM, and a config file you can read top to bottom. On a small VPS that matters. Step 1: a private network and shared databases Create one external network that apps and databases share: docker network create db
Then a single compose file for the database engines: services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
networks: [db]
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg-data:/var/lib/postgresql/data
networks: [db]
volumes:
mysql-data:
pg-data:
networks:
db:
external: true
No ports are published, so the databases can't be reached from the internet at all. Apps connect by service name (mysql, postgres), and when you need to open a database from your laptop, an SSH tunnel does the job. Give every project its own user and database per environment, for example shop_prod and shop_dev. A bad migration on dev then can't touch production data. Step 2: one compose file per app Here is a typical Next.js app: services:
web:
image: ghcr.io/your-org/shop-web:${TAG:-latest}
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:3101:3000"
networks: [db]
networks:
db:
external: true
Keep a plain ports.txt that maps each project and environment to a port, for example 31xx for prod and 32xx for dev. It sounds trivial, but it is the thing that stops two apps fighting over the same port six months from now. Step 3: an Nginx server block per domain server {
server_name shop.example.com;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:3101;
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;
}
}
Then issue a certificate: sudo certbot --nginx -d shop.example.com
Dev copies get a subdomain such as dev.shop.example.com pointing to the 32xx port, behind basic auth so search engines and random visitors never see half-finished work. We wrapped this in a tiny new-site.sh script that fills the template, runs nginx -t and reloads. Adding a new project is a few commands instead of a small adventur...

Powered by Readybot.io