Ejemplos¶
Un deploy.yml listo para cada framework, probado de punta a punta por el test e2e en Ubuntu 24.04 (y, salvo FrankenPHP, también en AlmaLinux 9). Cópialo a tu proyecto y cambia lo marcado con # change; Primeros pasos explica cada paso.
curl -fsSL -o deploy.yml https://raw.githubusercontent.com/kriollo/versaDeploy/main/examples/deploy.laravel.yml
Todas las opciones están en la referencia de configuración. Para CI hay un workflow de GitHub Actions al final.
Laravel¶
PHP-FPM detrás de nginx, PostgreSQL, Redis, migraciones, cron y storage. Video.
deploy.laravel.yml
# Laravel (PHP-FPM behind nginx). Copy to your project as deploy.yml and edit the
# values marked "change". Tested end to end with a fresh Laravel on Ubuntu 24.04
# (PostgreSQL) and AlmaLinux 9 (MariaDB).
#
# First deploy:
# 1. versa → Services: install the stack (I), then w (save provision) and a (services_reload)
# 2. versa deploy production --initial-deploy → answer N to the post_deploy hooks
# 3. versa → Shared → .env → Enter → e: set APP_KEY (php artisan key:generate --show
# on your machine), DB_*, MAIL_*… Ctrl+S saves it on the server
# 4. versa deploy production --force (runs the migrations and caches)
project: "myapp" # change
environments:
production:
ssh:
host: "myapp.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/myapp"
provision: # read only by the Services view
ssh: { user: root }
domain: "myapp.com" # change
aliases: ["www.myapp.com"]
web_server: nginx
php_version: "8.4"
doc_root: "current/app/public" # Laravel serves public/
database: postgres # or mariadb (DB_CONNECTION=mariadb, DB_HOST=localhost)
db_name: myapp
db_user: myapp_user
db_password: "${DB_PASS}" # only used to create the user: DB_PASS=… versa
redis: true # CACHE_STORE=redis / SESSION_DRIVER=redis (or dragonfly: true)
ssl: true
backup: true
deploy_user: deploy
cron:
- "* * * * * php artisan schedule:run >> /dev/null 2>&1"
ignored_paths:
- ".git"
- ".github"
- "tests"
- "node_modules" # assets are built into public/build
- "vendor/bin"
# .env lives in shared/: the first deploy creates it from .env.example.
# storage/ too (logs, sessions, uploads): seeded once from the release.
shared_paths:
- ".env"
- "storage"
builds:
php:
enabled: true
composer_command: "composer install --no-dev --optimize-autoloader"
frontend: # Vite: public/build
enabled: true
npm_command: "npm ci"
compile_command: "npm run build"
services_reload: # PHP-FPM must reload: OPcache keeps the old release
- "sudo systemctl reload php8.4-fpm"
- "sudo systemctl reload nginx"
post_deploy:
- "php artisan migrate --force"
- "php artisan optimize" # config, routes, views, events cache (reads .env)
- "php artisan storage:link --force"
# - "php artisan queue:restart" # with the queue service below
# Queue worker (QUEUE_CONNECTION=database/redis): restarted on every deploy.
# services:
# - name: "myapp-queue"
# exec: "php artisan queue:work --sleep=3 --tries=3 --max-time=3600"
health_check:
url: "https://myapp.com/up" # Laravel's health route
Laravel con FrankenPHP¶
FrankenPHP como servicio en vez de PHP-FPM; nginx sigue sirviendo los estáticos.
deploy.laravel-frankenphp.yml
# Laravel on FrankenPHP behind nginx (no PHP-FPM). Copy to your project as deploy.yml
# and edit the values marked "change". Tested end to end with a fresh Laravel on
# Ubuntu 24.04 (PostgreSQL). nginx serves public/ and its rules; only index.php reaches
# FrankenPHP, which runs as the myapp service and reloads on every deploy.
#
# First deploy:
# 1. versa → Services: install the stack (I), then w (save provision) and a (services_reload)
# 2. versa deploy production --initial-deploy → answer N to the post_deploy hooks
# 3. versa → Shared → .env → Enter → e: set APP_KEY (php artisan key:generate --show
# on your machine), DB_*, MAIL_*… Ctrl+S saves it on the server
# 4. versa deploy production --force (runs the migrations and caches)
project: "myapp" # change
environments:
production:
ssh:
host: "myapp.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/myapp"
provision: # read only by the Services view
ssh: { user: root }
domain: "myapp.com" # change
aliases: ["www.myapp.com"]
web_server: nginx
frankenphp: true # instead of PHP-FPM (one PHP per server): PHP 8.5 + extensions
proxy_port: 8000 # FrankenPHP listens on 127.0.0.1:8000
doc_root: "current/app/public" # Laravel serves public/
database: postgres # or mariadb (DB_CONNECTION=mariadb, DB_HOST=localhost)
db_name: myapp
db_user: myapp_user
db_password: "${DB_PASS}" # only used to create the user: DB_PASS=… versa
redis: true # CACHE_STORE=redis / SESSION_DRIVER=redis (or dragonfly: true)
ssl: true
backup: true
deploy_user: deploy
cron:
- "* * * * * php artisan schedule:run >> /dev/null 2>&1" # php = frankenphp php-cli
ignored_paths:
- ".git"
- ".github"
- "tests"
- "node_modules" # assets are built into public/build
- "vendor/bin"
# .env lives in shared/: the first deploy creates it from .env.example.
# storage/ too (logs, sessions, uploads): seeded once from the release.
shared_paths:
- ".env"
- "storage"
builds:
php:
enabled: true
composer_command: "composer install --no-dev --optimize-autoloader"
frontend: # Vite: public/build
enabled: true
npm_command: "npm ci"
compile_command: "npm run build"
services_reload:
- "sudo systemctl reload nginx"
# The Services view proposes it (w) and installs its config, /etc/frankenphp/myapp.Caddyfile
services:
- name: "myapp"
exec: "/usr/local/bin/frankenphp run --config /etc/frankenphp/myapp.Caddyfile"
# deploys don't restart it (no dropped requests): the new release runs by itself and
# this frees the old one's OPcache
reload: "curl -fsS --unix-socket /var/www/myapp/.frankenphp-opcache.sock http://localhost/"
post_deploy:
- "php artisan migrate --force"
- "php artisan optimize" # config, routes, views, events cache (reads .env)
- "php artisan storage:link --force"
# - "php artisan queue:restart" # with the queue service below
# Queue worker (QUEUE_CONNECTION=database/redis): restarted on every deploy.
# - name: "myapp-queue"
# exec: "php artisan queue:work --sleep=3 --tries=3 --max-time=3600"
health_check:
url: "https://myapp.com/up" # Laravel's health route
Next.js¶
npm start como servicio; node_modules se instala en el servidor.
deploy.nextjs.yml
# Next.js (SSR) as a service behind nginx. Tested end to end with Next.js 16 on
# Ubuntu 24.04 and AlmaLinux 9. package.json needs "start": "next start".
#
# Runtime variables (server only): shared/.env, edited in versa → Shared → Enter → e,
# then `versa service production restart`. NEXT_PUBLIC_* are build-time: they come
# from your machine when versa builds.
project: "myshop" # change
environments:
production:
ssh:
host: "myshop.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/myshop"
provision:
ssh: { user: root }
domain: "myshop.com" # change
aliases: ["www.myshop.com"]
web_server: nginx
node_major: "22"
proxy_port: 3000 # nginx → the service
database: none # or postgres / mariadb
ssl: true
deploy_user: deploy
ignored_paths:
- ".git"
- ".github"
- "node_modules/.cache"
- ".next/cache"
shared_paths:
- ".env" # created from .env.example on the first deploy
builds:
frontend:
enabled: true
npm_command: "npm ci"
compile_command: "NEXT_TELEMETRY_DISABLED=1 npm run build"
# node_modules installed on the server: native modules (sharp…) for Linux.
# Reused while package-lock.json doesn't change. Ship the lockfile.
server_install: "npm ci --omit=dev"
services:
- name: "myshop"
exec: "npm start"
env_file: ".env" # <remote_path>/shared/.env
environment:
PORT: "3000"
NODE_ENV: "production"
NEXT_TELEMETRY_DISABLED: "1"
health_check:
url: "https://myshop.com/"
Nuxt¶
.output/server con Node como servicio. Video.
deploy.nuxt.yml
# Nuxt (Nitro node-server) as a service behind nginx. Tested end to end with Nuxt 4
# on Ubuntu 24.04 and AlmaLinux 9. .output/ bundles its own dependencies, so no
# node_modules goes to the server.
#
# Runtime config: NUXT_* variables in shared/.env (versa → Shared → Enter → e), then
# `versa service production restart`. NUXT_PUBLIC_* also work at runtime.
project: "mysite" # change
environments:
production:
ssh:
host: "mysite.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/mysite"
provision:
ssh: { user: root }
domain: "mysite.com" # change
aliases: ["www.mysite.com"]
web_server: nginx
node_major: "22"
proxy_port: 3000
database: none
ssl: true
deploy_user: deploy
ignored_paths:
- ".git"
- ".github"
- ".nuxt"
- "node_modules"
shared_paths:
- ".env"
builds:
frontend:
enabled: true
npm_command: "npm ci"
compile_command: "npm run build"
services:
- name: "mysite"
exec: "node .output/server/index.mjs"
env_file: ".env"
environment:
PORT: "3000"
NODE_ENV: "production"
health_check:
url: "https://mysite.com/"
FastAPI¶
uvicorn como servicio, virtualenv en el servidor, PostgreSQL. Video.
deploy.fastapi.yml
# FastAPI behind uvicorn and nginx. Tested end to end on Ubuntu 24.04 and AlmaLinux 9.
# requirements.txt must include fastapi and uvicorn; main.py defines `app = FastAPI()`.
#
# Secrets: shared/.env (created from .env.example on the first deploy); edit it in
# versa → Shared → Enter → e, then `versa service production restart`.
project: "myapi" # change
environments:
production:
ssh:
host: "api.example.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/myapi"
provision:
ssh: { user: root }
domain: "api.example.com" # change
web_server: nginx
proxy_port: 8000
database: postgres
db_name: myapi
db_user: myapi_user
db_password: "${DB_PASS}"
ssl: true
backup: true
deploy_user: deploy
ignored_paths:
- ".git"
- ".github"
- "tests"
- "__pycache__"
- ".venv"
shared_paths:
- ".env"
builds:
python:
enabled: true
requirements_file: "requirements.txt"
web_server: true
web_framework: fastapi # run_server.sh: uvicorn main:app
entry_point: "main.py" # api/main.py → uvicorn api.main:app
web_host: "127.0.0.1"
web_port: 8000
web_workers: 2
services:
- name: "myapi"
env_file: ".env"
# post_deploy:
# - ".venv/bin/alembic upgrade head"
health_check:
url: "https://api.example.com/docs"
Flask¶
gunicorn como servicio, virtualenv en el servidor.
deploy.flask.yml
# Flask behind gunicorn and nginx. Tested end to end on Ubuntu 24.04 and AlmaLinux 9.
# requirements.txt must include flask and gunicorn; app.py defines `app = Flask(...)`.
# The virtualenv is created on the server (reused while requirements.txt doesn't change).
#
# Secrets: shared/.env, created from .env.example on the first deploy; edit it in
# versa → Shared → Enter → e, then `versa service production restart`.
project: "myapi" # change
environments:
production:
ssh:
host: "api.example.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/myapi"
provision:
ssh: { user: root }
domain: "api.example.com" # change
web_server: nginx
proxy_port: 8000
database: postgres # or mariadb / none
db_name: myapi
db_user: myapi_user
db_password: "${DB_PASS}"
ssl: true
backup: true
deploy_user: deploy
ignored_paths:
- ".git"
- ".github"
- "tests"
- "__pycache__"
- ".venv"
shared_paths:
- ".env"
builds:
python:
enabled: true
requirements_file: "requirements.txt"
web_server: true
web_framework: gunicorn # run_server.sh: gunicorn app:app
entry_point: "app.py"
web_host: "127.0.0.1" # only nginx reaches it
web_port: 8000
web_workers: 2
services:
- name: "myapi" # exec: the release's run_server.sh
env_file: ".env"
# post_deploy:
# - ".venv/bin/flask --app app db upgrade" # Flask-Migrate
health_check:
url: "https://api.example.com/"
Go (Gin)¶
Binario estático compilado en tu computador, como servicio.
deploy.gin.yml
# Go with Gin behind nginx. Tested end to end on Ubuntu 24.04 and AlmaLinux 9.
# The binary is cross-compiled on your machine (static, CGO_ENABLED=0): the server
# needs no Go. main.go listens on 127.0.0.1:$PORT.
#
# Secrets: shared/.env (created from .env.example on the first deploy); edit it in
# versa → Shared → Enter → e, then `versa service production restart`.
project: "myapi" # change
environments:
production:
ssh:
host: "api.example.com" # change
user: "deploy"
key_path: "~/.ssh/id_ed25519"
remote_path: "/var/www/myapi"
provision:
ssh: { user: root }
domain: "api.example.com" # change
web_server: nginx
proxy_port: 8080
database: postgres
db_name: myapi
db_user: myapi_user
db_password: "${DB_PASS}"
ssl: true
backup: true
deploy_user: deploy
ignored_paths:
- ".git"
- ".github"
shared_paths:
- ".env"
builds:
go:
enabled: true
target_os: linux
target_arch: amd64 # arm64 for Graviton/Ampere servers
binary_name: "api"
deploy_path: "bin" # → current/bin/api
build_flags: "-trimpath -ldflags=-s"
services:
- name: "myapi" # exec: current/bin/api
env_file: ".env"
environment:
PORT: "8080"
GIN_MODE: "release"
health_check:
url: "https://api.example.com/"
GitHub Actions¶
Staging en cada push a main, producción en cada tag v*.
github-actions.yml
# Deploy from GitHub Actions: copy to .github/workflows/deploy.yml.
#
# - every push to main deploys staging;
# - a version tag (git tag v1.2.0 && git push --tags) deploys production. Add
# `deploy_tags: "v*"` to production in deploy.yml so versa refuses anything else,
# from CI or from a laptop.
#
# Repository secrets (Settings → Secrets and variables → Actions):
# DEPLOY_SSH_KEY private key of the deploy user (ssh-keygen -t ed25519 -f ci_deploy)
# DEPLOY_KNOWN_HOSTS output of `ssh-keyscan -t ed25519 your-server` (checked by versa)
#
# deploy.yml reads the key from ~/.ssh/id_ed25519, as in the examples; set
# `strict_host_key: true` under ssh so CI never connects to an unknown host.
name: Deploy
on:
push:
branches: [main]
tags: ["v*"]
# one deploy per environment at a time; a newer push waits for the running one
concurrency:
group: deploy-${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'staging' }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # tags and full history: versa clones this checkout
# The toolchains your builds use (remove the ones you don't need)
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install versa
run: |
curl -fsSL https://raw.githubusercontent.com/kriollo/versaDeploy/main/install.sh | VERSA_INSTALL_DIR="$HOME/.local/bin" sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: SSH key and known host
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Deploy
env:
VERSA_DEPLOYER: ${{ github.actor }} # who shows up in versa history
run: versa deploy ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'staging' }} --config deploy.yml