Plan de Migración
a VPS Propio
DigitalOcean · Ubuntu 24.04 · Nginx · PM2 · Let's Encrypt
Browser └── GoDaddy DNS (A records → IP del VPS) └── DigitalOcean VPS (Ubuntu 24.04, 512 MB RAM) ├── Nginx (reverse proxy + SSL termination) │ ├── www.mydomain.com → localhost:3000 │ ├── budget.mydomain.com → localhost:3001 │ └── app.mydomain.com → localhost:3002 └── PM2 (process manager — mantiene las apps corriendo)
| Proceso | RAM idle aprox. |
|---|---|
| OS + sistema base | ~100 MB |
| Nginx + fail2ban | ~20 MB |
| Portafolio www (Next.js) | ~120 MB |
| App adicional (Node.js) | ~100 MB |
| Total | ~340 MB |
¿Por qué DigitalOcean sobre AWS?
Los Droplets tienen precio fijo y predecible (no hay sorpresas en la factura). El panel es simple y la documentación está entre las mejores del sector.
AWS o GCP añaden complejidad (IAM, VPC, egress costs) sin beneficio real para proyectos personales. Con 512 MB RAM + 1 GB swap hay margen para 1–2 apps Node.js. Si agregas una tercera, considera el plan de 1 GB ($6/mes).
Crear el Droplet en DigitalOcean
Crear cuenta en DigitalOcean → Droplets → Create Droplet con esta configuración:
| Parámetro | Valor |
|---|---|
| OS | Ubuntu 24.04 x64 LTS |
| Plan | Basic $4/mes — 1 vCPU / 512 MB RAM |
| Región | La más cercana a tus visitantes |
| Autenticación | SSH key — nunca contraseña |
Generar SSH key
ssh-keygen -t ed25519 -C "tu@email.com" -f ~/.ssh/id_ed25519_digitalocean
¿Por qué ed25519?
Es el algoritmo más moderno de SSH — más seguro y con claves más cortas que RSA 4096. Todos los sistemas modernos lo soportan.
Nombrar la llave con -f ~/.ssh/id_ed25519_digitalocean permite tener múltiples llaves para distintos servicios (GitHub, otro VPS, etc.) sin que se mezclen.
# Copiar la clave pública para pegarla en DigitalOcean cat ~/.ssh/id_ed25519_digitalocean.pub # Verificar conexión ssh -i ~/.ssh/id_ed25519_digitalocean root@TU_IP_VPS
¿Por qué -i en el comando ssh?
Le indica a SSH qué llave privada usar. Si tienes varias llaves, sin este flag SSH prueba todas en orden y puede conectarse con la llave equivocada o fallar.
SSH config (opcional)
Agrega esto a ~/.ssh/config para no escribir -i cada vez:
Host TU_IP_VPS mydomain.com
User deploy
IdentityFile ~/.ssh/id_ed25519_digitalocean
root solo en esta primera conexión. Después de aplicar el paso 3, root quedará bloqueado por SSH y todas las conexiones serán como deploy.
Apuntar dominio GoDaddy al VPS
En el panel DNS de GoDaddy (My Products → DNS), agrega un registro A por cada subdominio:
| Tipo | Nombre | Valor | TTL |
|---|---|---|---|
A | @ | IP del VPS | 600 |
A | www | IP del VPS | 600 |
A | budget | IP del VPS | 600 |
¿Qué significan @ y TTL?
@ representa el dominio raíz (tudominio.com sin prefijo). Los otros registros como www se convierten en www.tudominio.com.
TTL (Time To Live) es el tiempo en segundos que otros servidores DNS cachean el registro. Con 600 (10 min), los cambios propagan rápido. Una vez estable, puedes subirlo a 3600.
Verificar propagación
dig www.tudominio.com +short # Debe devolver la IP del VPS
Configuración inicial del servidor
Conectarse como root la primera vez:
ssh root@TU_IP_VPS
apt update && apt upgrade -y apt install -y nano git curl wget
¿Por qué estas herramientas?
nano — editor para archivos de config como sshd_config.
git — para clonar repos en el servidor.
curl — peticiones HTTP desde terminal; usado para descargar el instalador de Node.js.
wget — descargar archivos. Algunos scripts lo prefieren sobre curl.
adduser deploy usermod -aG sudo deploy # Copiar SSH keys de root al nuevo usuario rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy # Dar permisos a Nginx sobre el home (evita 403) chmod o+x /home/deploy
¿Por qué no usar root para todo?
Root tiene permisos absolutos. Si una app tiene vulnerabilidad o hay un error de comando, puede borrar archivos o comprometer todo el servidor. Un usuario normal limita el daño potencial.
rsync --chown=deploy:deploy copia las llaves SSH autorizadas para que puedas conectarte como deploy usando la misma llave privada local.
chmod o+x /home/deploy es necesario porque Nginx corre como www-data y necesita poder atravesar el directorio. Sin esto verás 403 Forbidden.
nano /etc/ssh/sshd_config
Cambiar o agregar estas líneas en el archivo:
PermitRootLogin no PasswordAuthentication no
systemctl restart ssh
ssh deploy@TU_IP_VPS. Si algo salió mal, aún tienes la sesión de root activa para corregirlo.
ufw allow OpenSSH ufw allow http ufw allow https ufw enable # Verificar reglas activas ufw status
¿Por qué el orden importa?
ufw allow OpenSSH debe ejecutarse antes de ufw enable. Si no lo hicieras primero, al activar el firewall bloquearías tu propia conexión SSH y quedarías fuera del servidor.
apt install -y nginx systemctl enable --now nginx
¿Por qué --now?
enable configura Nginx para arrancar automáticamente en cada reboot. --now lo inicia inmediatamente, sin necesidad de correr systemctl start nginx por separado.
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - apt install -y nodejs node -v && npm -v
¿Por qué no apt install nodejs directamente?
Los repos de Ubuntu tienen versiones muy desactualizadas de Node.js (a veces años atrás). NodeSource es el repositorio oficial que siempre tiene la versión LTS actual (Node 20+).
npm install -g pm2
¿Por qué PM2?
Node.js por sí solo no se mantiene corriendo si el proceso falla o el servidor se reinicia. PM2 mantiene las apps vivas: las reinicia si crashean, las arranca al inicio del sistema, y permite correr múltiples apps en paralelo.
apt install -y certbot python3-certbot-nginx
fallocate -l 1G /swapfile chmod 600 /swapfile mkswap /swapfile swapon /swapfile # Hacer permanente echo '/swapfile none swap sw 0 0' >> /etc/fstab # Verificar free -h
¿Por qué swap con 512 MB?
El OS + Nginx consume ~120 MB. Una app Next.js suma otros ~120 MB — quedan menos de 300 MB libres. Con múltiples apps, esa reserva se agota. Cuando la RAM se agota, el kernel de Linux mata procesos (OOM killer) — normalmente mata la app más grande, causando downtime sin aviso.
El swap es espacio en disco que el sistema usa como RAM de emergencia. Mucho más lento, pero evita que las apps mueran abruptamente.
apt install -y fail2ban systemctl enable --now fail2ban
nano /etc/fail2ban/jail.local
[DEFAULT] bantime = 1h findtime = 10m maxretry = 5 [sshd] enabled = true
systemctl restart fail2ban fail2ban-client status sshd
Verificación final
systemctl status nginx systemctl status ufw systemctl status fail2ban node -v pm2 -v
Instalar MariaDB (opcional)
apt install -y mariadb-server systemctl enable --now mariadb mysql_secure_installation
El asistente pregunta paso a paso:
| Pregunta | Respuesta |
|---|---|
| Switch to unix_socket authentication? | n |
| Change the root password? | y (contraseña fuerte) |
| Remove anonymous users? | y |
| Disallow root login remotely? | y |
| Remove test database? | y |
| Reload privilege tables? | y |
Config low-memory (512 MB RAM)
nano /etc/mysql/conf.d/memory.cnf
[mysqld] innodb_buffer_pool_size = 64M key_buffer_size = 16M max_connections = 20 tmp_table_size = 16M max_heap_table_size = 16M
systemctl restart mariadb
Con esta config MariaDB consume ~80 MB en idle en lugar de los 300–500 MB por defecto.
Crear base de datos por app
mysql -u root -p
CREATE DATABASE budget_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'budget_user'@'localhost' IDENTIFIED BY 'contraseña_segura'; GRANT ALL PRIVILEGES ON budget_db.* TO 'budget_user'@'localhost'; FLUSH PRIVILEGES; EXIT;
Referencia rápida MariaDB
| Acción | Comando |
|---|---|
| Conectarse como root | mysql -u root -p |
| Ver bases de datos | SHOW DATABASES; |
| Ver usuarios | SELECT user, host FROM mysql.user; |
| Liberar RAM | sudo systemctl stop mariadb |
| Volver a levantar | sudo systemctl start mariadb |
Deploy — App Node.js con PM2
Para proyectos con servidor propio: Next.js, Express, Fastify, NestJS. Conectarse como deploy:
ssh deploy@TU_IP_VPS
Clonar y construir
mkdir -p ~/apps cd ~/apps git clone https://github.com/tuusuario/tu-app.git www cd www && npm install && npm run build
Iniciar con PM2
pm2 start node_modules/.bin/next --name "www" -- start -p 3000 curl http://localhost:3000
PORT=3000 pm2 start npm --name "www" -- start curl http://localhost:3000
Persistir entre reinicios
pm2 save pm2 startup # Copia y ejecuta el comando que PM2 imprima con sudo
Estrategias de deploy
name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm install - run: npm run build - name: Subir archivos al VPS uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.VPS_HOST }} username: deploy key: ${{ secrets.VPS_SSH_KEY }} source: ".next/,public/,package.json,package-lock.json" target: "~/apps/www" - name: Instalar y reiniciar uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: deploy key: ${{ secrets.VPS_SSH_KEY }} script: | cd ~/apps/www npm install --omit=dev pm2 restart www
VPS_HOST — IP del VPS · VPS_SSH_KEY — contenido de la clave privada SSH
#!/bin/bash set -e VPS="deploy@TU_IP_VPS" APP_DIR="~/apps/www" APP_NAME="www" echo "→ Building..." npm run build echo "→ Uploading..." rsync -avz --delete .next/ public/ package.json package-lock.json $VPS:$APP_DIR/ echo "→ Restarting..." ssh $VPS "cd $APP_DIR && npm install --omit=dev && pm2 restart $APP_NAME" echo "✓ Done"
chmod +x deploy.sh
./deploy.sh
cd ~/apps/www git pull npm install npm run build pm2 restart www
Deploy — Sitio Estático (Astro / React)
Para proyectos que generan archivos HTML/CSS/JS estáticos. Nginx los sirve directamente desde disco — sin Node.js, sin PM2.
# Setup inicial (una sola vez) ssh deploy@TU_IP_VPS "mkdir -p ~/apps/www" # Build y upload npm run build rsync -avz --delete dist/ deploy@TU_IP_VPS:~/apps/www/dist/
No hay proceso que iniciar. Nginx sirve desde ~/apps/www/dist/ directamente.
Script deploy.sh
#!/bin/bash set -e VPS="deploy@TU_IP_VPS" DIST_DIR="~/apps/www/dist" echo "→ Building..." npm run build echo "→ Uploading..." rsync -avz --delete dist/ $VPS:$DIST_DIR/ echo "✓ Done"
¿Qué hace rsync --delete?
Sincroniza solo los archivos que cambiaron entre tu máquina y el VPS — mucho más rápido que subir todo cada vez. --delete elimina archivos del VPS que ya no existen en tu dist/ local, evitando acumulación de builds viejos.
No se sube código fuente (src/, node_modules/, etc.) — solo la carpeta de salida del build.
Configurar Nginx como Reverse Proxy
sudo nano /etc/nginx/sites-available/www
server { listen 80; server_name tudominio.com www.tudominio.com; root /home/deploy/apps/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }
sudo nano /etc/nginx/sites-available/www
server { listen 80; server_name tudominio.com www.tudominio.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; 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; proxy_cache_bypass $http_upgrade; } }
Habilitar el sitio
sudo ln -s /etc/nginx/sites-available/www /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
Ejemplo: agregar budget.tudominio.com (puerto 3001)
sudo nano /etc/nginx/sites-available/budget sudo ln -s /etc/nginx/sites-available/budget /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx
Permisos
Nginx corre como www-data y necesita permiso x en cada directorio del path hasta los archivos del sitio.
# Verificar permisos ls -la /home/ ls -la /home/deploy/ # Dar permiso de ejecución en el home (si falta) chmod o+x /home/deploy # Dar permiso de lectura en los archivos del dist chmod -R o+r /home/deploy/apps/www/dist
Errores comunes
| Error | Causa probable | Solución |
|---|---|---|
| 403 Forbidden | Nginx no puede leer la carpeta | chmod o+x /home/deploy |
| 404 Not Found | root apunta a carpeta incorrecta | Verificar path con ls |
| 500 Internal Error | Config mal formada | sudo tail /var/log/nginx/error.log |
| 502 Bad Gateway | App Node.js no está corriendo | pm2 list + curl localhost:3000 |
SSL con Certbot
Emitir certificados
sudo certbot --nginx -d mydomain.com -d www.mydomain.com sudo certbot --nginx -d budget.mydomain.com
Certbot modifica automáticamente los archivos de Nginx para: agregar listen 443 ssl, redirigir HTTP → HTTPS y apuntar a los certificados en /etc/letsencrypt/live/.
Verificar renovación automática
# Probar sin renovar realmente sudo certbot renew --dry-run # Ver el timer de systemd (renueva cada 90 días) systemctl status snap.certbot.renew.timer
Verificación final
sudo nginx -t sudo systemctl reload nginx
Abre en el navegador: https://www.mydomain.com — debe mostrar candado verde.
Agregar Nuevas Apps en el Futuro
El patrón es siempre el mismo. Para cada nuevo proyecto:
cd ~/apps git clone https://github.com/tuusuario/nueva-app.git nueva-app cd nueva-app && npm install && npm run build pm2 start node_modules/.bin/next --name "nueva-app" -- start -p 3002 pm2 save
| Tipo | Nombre | Valor | TTL |
|---|---|---|---|
A | nueva-app | IP del VPS | 600 |
sudo nano /etc/nginx/sites-available/nueva-app
server { listen 80; server_name nueva-app.mydomain.com; location / { proxy_pass http://localhost:3002; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_cache_bypass $http_upgrade; } }
sudo ln -s /etc/nginx/sites-available/nueva-app /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d nueva-app.mydomain.com
Checklist antes de cortar Vercel
Pre-launch checklist
Referencia Rápida
PM2
| Acción | Comando |
|---|---|
| Ver apps corriendo | pm2 list |
| Ver logs de una app | pm2 logs www |
| Reiniciar una app | pm2 restart www |
| Detener una app (libera RAM) | pm2 stop www |
| Levantar una app detenida | pm2 start www |
| Monitoreo en tiempo real | pm2 monit |
| Guardar estado actual | pm2 save |
Nginx
| Acción | Comando |
|---|---|
| Verificar configuración | sudo nginx -t |
| Recargar configuración | sudo systemctl reload nginx |
| Ver logs de errores | sudo tail -f /var/log/nginx/error.log |
| Ver logs de acceso | sudo tail -f /var/log/nginx/access.log |
SSL
| Acción | Comando |
|---|---|
| Emitir certificado | sudo certbot --nginx -d dominio.com |
| Renovar manualmente | sudo certbot renew |
| Probar renovación | sudo certbot renew --dry-run |
Firewall (ufw)
| Acción | Comando |
|---|---|
| Ver reglas activas | sudo ufw status |
| Abrir un puerto | sudo ufw allow 8080/tcp |
Actualizar una app desde Git
cd ~/apps/www git pull npm install npm run build pm2 restart www
Sistema
| Acción | Comando |
|---|---|
| Ver uso de RAM | free -h |
| Ver uso de disco | df -h |
| Ver logs del sistema | sudo journalctl -xe |
| Revisar logs de arranque | sudo journalctl -b |