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 ansible

On Windows, Ansible requires WSL:

wsl --install
# Inside WSL:
sudo apt update && sudo apt install ansible

Step 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: 4010

Step 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.yml

Edit 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 vars

Generate strong passwords with:

openssl rand -base64 32

Important: secrets.yml is gitignored. Never commit it to version control.

Step 4: Deploy

From the project root:

pnpm vm:deploy

The 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 -- -c

Deploy 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 PostgreSQL

Available tags: common, postgres, redis, minio, nodejs, caddy, app.

How blue-green deployment works

The system maintains two copies of your application: blue and green.

  1. Initial state — blue is active, serving all traffic.
  2. New deployment — code is deployed to green, built, and started.
  3. Health check — the system verifies green is responding on /api/health.
  4. Traffic switch — Caddy is reloaded to route traffic to green (zero downtime).
  5. Drain — waits 60 seconds for existing connections to complete.
  6. Cleanup — blue services are stopped (but code is preserved for rollback).
  7. Next deploy — repeats in reverse (deploys to blue).

Service ports

ServiceBlueGreen
Backend30114011
Frontend30104010

Rollback

If a deployment causes issues, instantly switch back to the previous version:

pnpm vm:rollback

Rollback 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-blue

Wildcard SSL (subdomains)

For multi-domain organization mode with wildcard subdomains (*.yourdomain.com), the system uses Cloudflare DNS-01 challenge instead of standard HTTP-01.

  1. Set subdomain_enabled: true in group_vars/all.yml.
  2. 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 -- -f

CI/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 errors

Connection issues

  1. Verify the inventory file has the correct IP.
  2. Test SSH: ssh root@YOUR_SERVER_IP
  3. Check your SSH key is loaded: ssh-add -l
  4. Verify DNS: dig yourdomain.com should 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: true in all.yml.