A production-ready Docker Compose stack for running multiple sites and services of different types on a single VPS — static HTML, React/Vite, WordPress, Node.js/Express, and Go services all managed from one place with a simple CLI.
Most Docker setups handle one type of site. This handles them all:
| Type | Example | Served by |
|---|---|---|
| Static HTML | marketing site | Nginx directly |
| React / Vite | SPA app | Nginx directly |
| WordPress | blog, CMS | Nginx + PHP-FPM |
| Node.js | Express API, OAuth service | PM2 in node container |
| Go service | SSE service, chat backend | PM2 (pre-built binary) |
- Nginx — reverse proxy, SSL termination, static file serving
- PHP-FPM — shared across all WordPress sites
- MySQL 8 — shared DB server, one database per WordPress site
- Node 20 + PM2 — all Node.js and Go services in one container
- Redis — session/token storage for Node services
- SFTP — key-only file access on port 2222
- Certbot — automatic Let's Encrypt SSL renewal
git clone https://github.com/giroguy/docker-polystack-webserver.git /srv/server
cd /srv/server
cp .env.example .env
nano .env # fill in passwordsGenerate secure passwords:
openssl rand -base64 32 # run once per passwordchmod +x site scripts/backup.shdocker compose up -d
docker compose ps # verify all containers running./site add --name mysite --domain mysite.com --type staticThen deploy your files to static/mysite/ and run:
./site ssl --domain mysite.com --wwwManages sites without touching Docker or restarting containers.
./site add --name mysite --domain mysite.com --type static
./site add --name myapp --domain myapp.com --type react
./site add --name myblog --domain myblog.com --type wordpress
./site add --name myapi --domain api.mysite.com --type node --port 3001
./site add --name myfull --domain myfull.com --type fullstack --port 3001
./site ssl --domain mysite.com # Let's Encrypt cert
./site ssl --domain mysite.com --www # includes www subdomain
./site remove --name mysite
./site listWhat it does:
- Generates Nginx vhost from template
- Creates site directory (
static/<name>/orwordpress/<name>/) - Reloads Nginx with zero downtime
- Auto-updates the site registry (
.sites)
Note: The ssl command only auto-updates nginx configs for sites managed via ./site add. Hand-crafted configs (multi-service vhosts) are never touched automatically.
./site add --name mysite --domain mysite.com --type static
# Deploy files to: static/mysite/React apps (--type react) use try_files for client-side routing — same nginx config, just note your build output goes to static/mysite/.
./site add --name myblog --domain myblog.com --type wordpressThen:
- Create the database in
mysql/init/01-create-databases.sqland restart MySQL - Deploy WordPress files to
wordpress/myblog/ - Configure
wp-config.phpto usegetenv()for DB credentials:
define('DB_NAME', 'myblog');
define('DB_USER', getenv('MYSQL_USER'));
define('DB_PASSWORD', getenv('MYSQL_PASSWORD'));
define('DB_HOST', 'mysql');
define('WP_HOME', 'https://myblog.com');
define('WP_SITEURL', 'https://myblog.com');- Deploy service files to
node/<service-name>/ - Add entry to
node/ecosystem.config.js - Install dependencies:
docker exec node sh -c "cd /app/<service-name> && npm install" - Reload PM2:
docker exec node pm2 reload ecosystem.config.js --update-env
- Build binary on CI (GitHub Actions) with
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 - Deploy binary to
go/<service-name>/ - Add entry to
node/ecosystem.config.jswithinterpreter: 'none'
./site add --name myapp --domain myapp.com --type fullstack --port 3001Serves static frontend from static/myapp/ and proxies /api to the Express service on the specified port. Vite dev proxy and Nginx both use /api — no code changes needed between environments.
docker-polystack-webserver/
├── docker-compose.yml
├── .env # secrets (not in git)
├── .env.example # template
├── site # CLI for managing sites
├── .sites # site registry (auto-managed)
├── nginx/
│ ├── conf.d/ # vhost configs (one per site)
│ └── templates/ # templates used by ./site add
├── static/ # static + React site files
├── wordpress/ # WordPress site files
├── node/ # Node/PM2 config
│ ├── Dockerfile # node:20-alpine + Go + PM2
│ └── ecosystem.config.js # PM2 app registry
├── go/ # Go service binaries
├── php/ # PHP-FPM config
├── mysql/
│ ├── init/ # SQL files run on first boot
│ └── my.cnf # MySQL tuning for small VPS
├── sftp/
│ └── authorized_keys # SFTP access (one key per line)
└── scripts/
└── backup.sh # nightly DB backup via rsync
Copy the right template to each site repo as .github/workflows/deploy.yml:
| Template | Use for |
|---|---|
static-react-deploy.yml |
Static HTML or React/Vite sites |
wordpress-deploy.yml |
WordPress sites |
node-deploy.yml |
Node.js services |
fullstack-deploy.yml |
Full-stack Vite + Express |
All templates use SCP (not git pull) — works with private repos without needing GitHub SSH access configured on the deploy user.
| Name | Type | Value |
|---|---|---|
SSH_DEPLOY_KEY |
Secret | Deploy user private key |
SERVER_HOST |
Variable | Server IP address |
Add public keys to sftp/authorized_keys (one per line), then:
docker compose restart sftpConnect: sftp -P 2222 deploy@YOUR_SERVER_IP
Backs up all MySQL databases and rsyncs them to any SSH-accessible remote server (NAS, VPS, cloud storage with sshfs, etc.).
Edit scripts/backup.sh to set your remote host, user, path, and SSH key.
One-time setup:
ssh-keygen -t ed25519 -f /root/.ssh/nas_backup -N ""
cat /root/.ssh/nas_backup.pub # add to NAS authorized_keysAdd cron job:
0 2 * * * /srv/server/scripts/backup.sh >> /var/log/backup.log 2>&1./site ssl --domain mysite.com
./site ssl --domain mysite.com --www # include www- Generate origin cert in Cloudflare dashboard
- Drop
.pemand.keyintonginx/ssl/ - Reference them in the vhost config
MySQL is exposed on 127.0.0.1:3306 (localhost only). Connect via SSH tunnel:
- MySQL Host:
127.0.0.1 - Port:
3306 - SSH Host: your server IP
- SSH User:
root - SSH Key: your local machine key
- Ubuntu 22.04 LTS
- Any VPS with 1GB+ RAM (DigitalOcean, Linode, Hetzner, Vultr, etc.)
- Docker 24+, Docker Compose v2
MIT