HE-LLO: despliegue de una plataforma multilenguaje en VPS con Laravel, Vue.js y Docker
Cómo configuré y desplegué desde cero la plataforma web de HE-LLO S.A. en un VPS: stack LEMP containerizado con Docker, Nginx como reverse proxy, SSL automático, variables de entorno y CI manual con Git hooks.
Contexto
Durante mi tiempo en HE-LLO S.A. (2022–2023), participé en el diseño y despliegue de la plataforma web corporativa: una aplicación multilenguaje construida con Laravel en el backend y Vue.js en el frontend.
El reto técnico principal no fue el código en sí, sino llevar esa aplicación a producción de forma ordenada, reproducible y segura sobre un VPS, usando prácticamente el mismo stack con el que trabajo hoy en mi sitio personal. Este artículo documenta esa experiencia.
El stack
| Capa | Tecnología | |------|-----------| | Backend API | Laravel 10 (PHP 8.2) | | Frontend | Vue.js 3 + Vite | | Base de datos | MySQL 8.0 | | Servidor web | Nginx 1.25 | | Containerización | Docker + Docker Compose | | SSL | Let's Encrypt + Certbot | | OS del VPS | Ubuntu 22.04 LTS | | Proveedor VPS | DigitalOcean (Droplet 2 vCPU / 4 GB RAM) |
El nombre del stack es LEMP: Linux + (E)Nginx + MySQL + PHP. Containerizado, cada servicio corre en su propio contenedor con recursos aislados.
Arquitectura del proyecto
he-llo-app/
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── nginx/
│ └── conf.d/
│ └── app.conf
├── backend/ # Laravel
│ ├── Dockerfile
│ └── ...
└── frontend/ # Vue.js
├── Dockerfile
└── ...
La separación entre docker-compose.yml (desarrollo) y docker-compose.prod.yml (producción) es clave: permite tener hot-reload en local sin contaminar la configuración de producción.
Dockerfiles
Backend — Laravel (PHP-FPM)
# backend/Dockerfile
FROM php:8.2-fpm-alpine
# Dependencias del sistema
RUN apk add --no-cache \
bash curl git zip unzip \
libpng-dev libjpeg-dev freetype-dev \
oniguruma-dev libxml2-dev
# Extensiones PHP
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \
pdo pdo_mysql mbstring exif pcntl bcmath gd xml opcache
# Composer
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copiar código y dependencias
COPY . .
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Permisos de Laravel
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]
Frontend — Vue.js (build estático)
# frontend/Dockerfile
# Etapa 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etapa 2: servir con Nginx
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
El build multi-etapa es importante: la imagen final solo contiene el HTML/CSS/JS compilado y Nginx. No lleva Node.js, node_modules ni código fuente.
# nginx-spa.conf — manejo de rutas en SPA (Vue Router)
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Cache de assets estáticos
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Docker Compose de producción
# docker-compose.prod.yml
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
depends_on:
- backend
- frontend
restart: always
certbot:
image: certbot/certbot
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
entrypoint: >
sh -c "trap exit TERM;
while :; do
certbot renew --webroot -w /var/www/certbot --quiet;
sleep 12h & wait $${!};
done"
backend:
build:
context: ./backend
dockerfile: Dockerfile
env_file: .env
volumes:
- ./backend/storage:/var/www/html/storage
depends_on:
db:
condition: service_healthy
restart: always
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: always
db:
image: mysql:8.0
env_file: .env
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 5
restart: always
redis:
image: redis:7-alpine
restart: always
volumes:
db_data:
El healthcheck en MySQL es fundamental: evita que el backend intente conectarse antes de que la base de datos esté lista, un error silencioso muy común en primeros despliegues.
Configuración de Nginx en el VPS
# nginx/conf.d/app.conf
# Redirigir HTTP → HTTPS
server {
listen 80;
server_name he-llo.com www.he-llo.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name he-llo.com www.he-llo.com;
ssl_certificate /etc/letsencrypt/live/he-llo.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/he-llo.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Frontend Vue.js
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# API Laravel — prefijo /api
location /api/ {
proxy_pass http://backend:9000;
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;
fastcgi_pass backend:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
}
}
El routing es simple: todo lo que empieza con /api/ va al contenedor de Laravel (PHP-FPM), el resto va al frontend estático de Vue.
Variables de entorno y seguridad
Nunca se sube el .env al repositorio. En el VPS se crea manualmente:
# En el VPS, dentro del directorio del proyecto
cp .env.example .env
nano .env # editar los valores reales
# .env (estructura, sin valores reales)
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:... # generada con php artisan key:generate
APP_URL=https://he-llo.com
DB_CONNECTION=mysql
DB_HOST=db # nombre del servicio en Docker
DB_PORT=3306
DB_DATABASE=hello_db
DB_USERNAME=hello_user
DB_PASSWORD=...
REDIS_HOST=redis
REDIS_PORT=6379
# i18n — idiomas disponibles
APP_LOCALE=es
APP_FALLBACK_LOCALE=en
APP_SUPPORTED_LOCALES=es,en
El
DB_HOST=dbapunta al nombre del servicio Docker, no alocalhost. Dentro de la red de Docker Compose, los servicios se resuelven por su nombre.
Internacionalización (i18n) en Laravel + Vue
La plataforma soporta español e inglés. La estrategia fue:
Backend (Laravel): el idioma se detecta por el header Accept-Language o por un parámetro en la URL (/es/... / /en/...). El middleware SetLocale aplica App::setLocale() en cada request.
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next): Response
{
$locale = $request->segment(1); // extrae 'es' o 'en' de la URL
if (in_array($locale, config('app.supported_locales'))) {
App::setLocale($locale);
}
return $next($request);
}
Frontend (Vue.js): usé vue-i18n con archivos de traducción por idioma. El idioma activo se persiste en localStorage y se sincroniza con la URL.
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import es from './locales/es.json'
import en from './locales/en.json'
export const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('locale') ?? 'es',
fallbackLocale: 'en',
messages: { es, en },
})
CI manual con Git hooks
Sin un sistema de CI/CD formal, implementé un post-receive hook en Git para automatizar el despliegue al hacer push a la rama main.
# En el VPS: crear un repositorio bare
mkdir -p /srv/git/hello.git
cd /srv/git/hello.git
git init --bare
# /srv/git/hello.git/hooks/post-receive
#!/bin/bash
set -e
TARGET="/srv/app/hello"
GIT_DIR="/srv/git/hello.git"
BRANCH="main"
while read oldrev newrev ref; do
if [[ $ref == "refs/heads/$BRANCH" ]]; then
echo "▶ Desplegando rama $BRANCH..."
git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH
cd $TARGET
echo "▶ Instalando dependencias PHP..."
docker compose -f docker-compose.prod.yml exec -T backend \
composer install --no-dev --optimize-autoloader
echo "▶ Ejecutando migraciones..."
docker compose -f docker-compose.prod.yml exec -T backend \
php artisan migrate --force
echo "▶ Limpiando caché..."
docker compose -f docker-compose.prod.yml exec -T backend \
php artisan config:cache
docker compose -f docker-compose.prod.yml exec -T backend \
php artisan route:cache
echo "▶ Reconstruyendo frontend..."
docker compose -f docker-compose.prod.yml build frontend
echo "▶ Reiniciando servicios..."
docker compose -f docker-compose.prod.yml up -d --no-deps frontend backend
echo "✅ Despliegue completado."
fi
done
chmod +x /srv/git/hello.git/hooks/post-receive
Desde el equipo local, el despliegue se reduce a:
# Agregar el remoto del VPS (solo una vez)
git remote add vps usuario@IP_VPS:/srv/git/hello.git
# Desplegar
git push vps main
Primer despliegue en el VPS
# 1. Conectarse al VPS
ssh usuario@IP_VPS
# 2. Instalar Docker y Docker Compose
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 3. Clonar el proyecto
git clone /srv/git/hello.git /srv/app/hello
cd /srv/app/hello
# 4. Crear .env de producción
cp .env.example .env && nano .env
# 5. Obtener certificado SSL (primer lanzamiento)
docker compose -f docker-compose.prod.yml run --rm certbot certonly \
--webroot -w /var/www/certbot \
-d he-llo.com -d www.he-llo.com \
--email jhonnyalbert245@gmail.com \
--agree-tos --no-eff-email
# 6. Levantar la stack completa
docker compose -f docker-compose.prod.yml up -d
# 7. Ejecutar migraciones iniciales
docker compose -f docker-compose.prod.yml exec backend \
php artisan migrate --seed
Lecciones aprendidas
Docker Compose en producción funciona perfectamente para proyectos de esta escala. No necesitas Kubernetes para una plataforma con tráfico moderado; la complejidad operacional no vale la pena.
Los healthchecks en la base de datos evitaron al menos 3 bugs de race condition durante el desarrollo inicial. Son una línea de YAML que salva horas de debugging.
El hook post-receive es elegante para equipos pequeños. No requiere ninguna herramienta externa y el flujo git push vps main es intuitivo para cualquier desarrollador.
La separación de archivos Compose (dev vs prod) parece overhead al principio pero es fundamental: en local quieres hot-reload, logs verbosos y sin SSL; en producción quieres lo contrario.
¿Tienes preguntas sobre este stack o el despliegue en VPS? Escríbeme en LinkedIn o por email.