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/<project>/<env>/ with its own docker-compose.yml and .env.
  • Ports: each app container binds to 127.0.0.1:<port> 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 <domain> <port> script that fills the template, runs nginx -t and reloads. Adding a new project is a few commands instead of a small adventure.

Step 4: deploy on push with GitHub Actions

Each repo carries its own Dockerfile, compose file and workflow. On every push to main, the workflow builds an image, pushes it to GitHub Container Registry, then SSHes into the server to pull and restart:

name: deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/your-org/shop-web:${{ github.sha }} - uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: deploy key: ${{ secrets.SERVER_SSH_KEY }} script: | cd /srv/apps/shop/prod export TAG=${{ github.sha }} docker compose pull docker compose up -d docker image prune -f 

Two details that save headaches: create a dedicated deploy user on the server that belongs to the docker group, and log the server in to ghcr.io once with a read-only token so it can pull private images.

What about Laravel, Node or plain React apps?

The same pattern works for any stack, because the host Nginx only ever sees a port. A Laravel app runs PHP-FPM behind a small web server inside its own compose file (or a single PHP image that bundles both), and the host Nginx proxies to it like any other app. Queue workers and the scheduler become extra services in the same compose file, reusing the same image:

 queue: image: ghcr.io/your-org/crm-api:${TAG:-latest} command: php artisan queue:work --tries=3 restart: unless-stopped env_file: .env networks: [db] 

A plain React or Vite build doesn't need a container at all. Build it in the workflow, copy the dist folder to /srv/static/<project> and let Nginx serve it with try_files $uri /index.html;. Node APIs look exactly like the Next.js example above.

Adding a new project: the checklist

  1. Pick the next free port from ports.txt and write it down.
  2. Create the database and user (<project>_prod, <project>_dev).
  3. Copy the compose template into /srv/apps/<project>/<env>/ and fill in .env.
  4. Run new-site.sh <domain> <port>, then certbot.
  5. Add the server secrets to the GitHub repo and push to main.

When adding a project takes ten minutes, nobody is tempted to "just run it on the same port for now", and that is how a shared server stays tidy.

Step 5: the boring things that save you later

  • Uptime checks: a small cron script that requests each domain every few minutes and sends a Telegram message and an email when something stops answering.
  • Backups: a nightly mysqldump / pg_dump per database, compressed, kept for a week, and copied off the server. A backup on the same disk is not a backup.
  • Log limits: Docker keeps container logs forever by default, which slowly fills the disk. Cap them in /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } 
  • Security updates: turn on unattended-upgrades and keep only ports 22, 80 and 443 open in the firewall.

When this stops being the right setup

This pattern comfortably runs a good number of small and medium apps on a single VPS. It stops being the right tool when you need zero-downtime deploys across several servers, autoscaling for traffic spikes, or a larger team where many people deploy every day. At that point a managed platform or an orchestrator starts paying for itself.

Until then, a few compose files, one Nginx config per domain and a short workflow per repo go a long way. We use this same pattern for the client projects we build at Appsvisor, from web applications and business websites to the admin panels and APIs behind our custom software projects. If anything here is unclear, ask in the comments and I'll expand on it.