VM deployment
Deploy your project to any Linux VM (Ubuntu/Debian) using Ansible. The deployment system includes blue-green deployments for zero downtime, automatic HTTPS via Caddy, and instant rollback.
What gets installed
The playbook provisions a complete production stack on a single server:
- Caddy — reverse proxy with automatic Let's Encrypt SSL
- PostgreSQL — database server
- Redis — caching and job queues
- MinIO — S3-compatible file storage (optional)
- Node.js — application runtime
- systemd — process management for backend, frontend, and worker
- fail2ban — SSH brute-force protection
- UFW — firewall (ports 22, 80, 443 only)
Prerequisites
Server
- A Linux VM running Ubuntu 22.04+ or Debian 12+
- Root SSH access via SSH key (password auth not required)
- A domain name pointing to the server's IP address
- Minimum recommended: 2 CPU, 4 GB RAM, 40 GB disk
Local machine
Install Ansible on your local machine (the machine you deploy from):
# macOS
brew install ansible
# Ubuntu/Debian
sudo apt update && sudo apt install ansible
# Other Linux (with pipx)
pipx install --include-deps ansibleOn Windows, Ansible requires WSL:
wsl --install
# Inside WSL:
sudo apt update && sudo apt install ansibleStep 1: Configure the inventory
Edit packages/deploy/vm/inventories/production.ini with your server details:
[project]
server1 ansible_host=YOUR_SERVER_IP ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
[project:vars]
ansible_python_interpreter=/usr/bin/python3
deploy_user=project
domain_name=yourdomain.com
ssl_email[email protected]
minio_domain=s3.yourdomain.com
minio_console_domain=s3-console.yourdomain.com- ansible_host — your server's IP address.
- domain_name — the domain for your app (must have DNS A record pointing to the server).
- ssl_email — email for Let's Encrypt certificate notifications.
- minio_domain / minio_console_domain — subdomains for MinIO (if file storage is enabled).
Step 2: Configure variables
Edit packages/deploy/vm/group_vars/all.yml to adjust settings:
# Service flags
minio_enabled: true # Set to false if no file uploads
postgres_enabled: true
subdomain_enabled: false # Set to true for wildcard SSL (requires Cloudflare)
# Application
app_name: project
node_version: '24'
# Git repository
git_repo: https://github.com/username/project.git
git_branch: main
git_use_token: true # Set to true for private repos
# Database
postgres_version: '18'
# Blue/Green ports
blue_backend_port: 3011
green_backend_port: 4011
blue_frontend_port: 3010
green_frontend_port: 4010Step 3: Configure secrets
Copy the secrets template and fill in your values:
cd packages/deploy/vm
cp group_vars/secrets.yml.example group_vars/secrets.ymlEdit group_vars/secrets.yml — key sections:
# GitHub token (for private repos)
github_token: 'ghp_your_token_here'
# Database password (generate with: openssl rand -base64 32)
postgres_password: 'your-strong-password'
# Redis password
redis_password: 'your-strong-password'
# MinIO credentials
minio_root_user: 'minio-admin'
minio_root_password: 'your-strong-password'
minio_app_user: 'minio-app'
minio_app_password: 'your-strong-password'
# Application environment variables
app_env_vars:
AUTH_SECRET: 'your-auth-secret'
EMAIL_FROM: '[email protected]'
EMAIL_SMTP_HOST: 'smtp.resend.com'
EMAIL_SMTP_PORT: '465'
EMAIL_SMTP_USER: 'resend'
EMAIL_SMTP_PASSWORD: 're_your_api_key'
BACKEND_URL: 'https://yourdomain.com'
FRONTEND_URL: 'https://yourdomain.com'
VITE_BACKEND_URL: '' # Must be empty for multi-domain mode
NODE_ENV: 'production'
BACKGROUND_JOB_MODE: 'worker'
# ... all other env varsGenerate strong passwords with:
openssl rand -base64 32Important:
secrets.ymlis gitignored. Never commit it to version control.
Step 4: Deploy
From the project root:
pnpm vm:deployThe first deployment takes longer as it installs all system packages, databases, and builds the application. Subsequent deployments are faster.
Dry run
Test what would change without making any modifications:
pnpm vm:deploy -- -cDeploy specific components
pnpm vm:deploy -- --tags app # Only deploy app code
pnpm vm:deploy -- --tags caddy # Only update Caddy config
pnpm vm:deploy -- --tags postgres # Only configure PostgreSQLAvailable tags: common, postgres, redis, minio, nodejs, caddy, app.
How blue-green deployment works
The system maintains two copies of your application: blue and green.
- Initial state — blue is active, serving all traffic.
- New deployment — code is deployed to green, built, and started.
- Health check — the system verifies green is responding on
/api/health. - Traffic switch — Caddy is reloaded to route traffic to green (zero downtime).
- Drain — waits 60 seconds for existing connections to complete.
- Cleanup — blue services are stopped (but code is preserved for rollback).
- Next deploy — repeats in reverse (deploys to blue).
Service ports
| Service | Blue | Green |
|---|---|---|
| Backend | 3011 | 4011 |
| Frontend | 3010 | 4010 |
Rollback
If a deployment causes issues, instantly switch back to the previous version:
pnpm vm:rollbackRollback is near-instant — it starts the previous color's services, verifies health, and switches Caddy. No rebuild needed.
Viewing logs
# Service status and active color
pnpm vm:logs status
# System resources (CPU, memory, disk)
pnpm vm:logs metrics
# Application logs
pnpm vm:logs backend
pnpm vm:logs frontend
pnpm vm:logs worker
# Infrastructure logs
pnpm vm:logs caddy
pnpm vm:logs postgres
pnpm vm:logs redis
# Follow logs in real-time
pnpm vm:logs backend -- -f
# Show more lines
pnpm vm:logs backend -- -n 200
# View specific color
pnpm vm:logs backend -- -s backend-blueWildcard SSL (subdomains)
For multi-domain organization mode with wildcard subdomains (*.yourdomain.com), the system uses Cloudflare DNS-01 challenge instead of standard HTTP-01.
- Set
subdomain_enabled: trueingroup_vars/all.yml. - Add your Cloudflare API token to
secrets.yml:
cloudflare_api_token: 'your-cloudflare-api-token'Create the token at Cloudflare API Tokens with Zone > DNS > Edit permission.
This builds a custom Caddy binary with the Cloudflare DNS plugin for wildcard certificate issuance.
Staging environment
Create a staging inventory at inventories/staging.ini and deploy with:
pnpm vm:deploy staging
pnpm vm:rollback staging
pnpm vm:logs staging status
pnpm vm:logs staging backend -- -fCI/CD integration
Example GitHub Action:
- name: Deploy to production
run: |
cd packages/deploy/vm
ansible-playbook -i inventories/production.ini playbook.yml
env:
ANSIBLE_HOST_KEY_CHECKING: 'false'Store secrets.yml as a CI/CD secret or encrypt it with ansible-vault encrypt secrets.yml.
Troubleshooting
Deployment failed
pnpm vm:logs status # Check service statuses
pnpm vm:logs metrics # Check disk space and memory
pnpm vm:logs backend -- -n 200 # View recent backend logs
pnpm vm:logs system # View system errorsConnection issues
- Verify the inventory file has the correct IP.
- Test SSH:
ssh root@YOUR_SERVER_IP - Check your SSH key is loaded:
ssh-add -l - Verify DNS:
dig yourdomain.comshould return the server IP.
SSL certificate not issuing
- Ensure your domain's DNS A record points to the server IP.
- Check Caddy logs:
pnpm vm:logs caddy - For wildcard SSL, verify the Cloudflare API token has DNS edit permissions.
- Try with staging certificates first by setting
caddy_staging: trueinall.yml.