Cuando un equipo trabaja en varios proyectos a la vez, cada uno con su propio lenguaje, versión y base de datos, instalarlo todo directamente en el sistema acaba en conflictos de versiones y en un servidor imposible de mantener. En este tutorial montarás un servidor de desarrollo en Ubuntu 24.04 donde cada proyecto vive aislado en su propio stack de Docker Compose y se publica en un subdominio propio (app1.dev.your_domain, app2.dev.your_domain) a través de Nginx, con HTTPS de Let's Encrypt y acceso protegido por contraseña.
Como ejemplo usarás dos proyectos con stacks distintos: una API en Node.js 22 y una aplicación PHP 8.3 con MariaDB. El mismo patrón sirve para cualquier otro proyecto que puedas meter en un contenedor.
Requisitos previos
Para seguir esta guía necesitas:
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 2 GB de RAM (4 GB si vas a ejecutar más de tres o cuatro proyectos con base de datos).
- Un usuario no root con privilegios
sudo. - Un dominio con acceso a su DNS. En esta guía se usa
your_domain; sustitúyelo por el tuyo. - Un registro DNS comodín de tipo A
*.dev.your_domainque apunte a la IP pública del servidor (your_server_ip). Así cualquier proyecto nuevo tendrá subdominio sin tocar el DNS. - UFW activo con SSH permitido.
Comprueba que el registro comodín resuelve antes de continuar:
dig +short app1.dev.your_domain
203.0.113.10
Paso 1: Instalar Docker Engine y Docker Compose
Docker aísla las dependencias de cada proyecto: cada uno puede usar su propia versión de Node, PHP o base de datos sin afectar a los demás. Instálalo desde el repositorio oficial de Docker:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Añade el repositorio:
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
Instala el motor y el plugin de Compose:
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Añade tu usuario al grupo docker para no tener que usar sudo con cada comando. Ten en cuenta que pertenecer a este grupo equivale a tener acceso root en el servidor, así que añade solo a usuarios de confianza:
sudo usermod -aG docker $USER
Cierra la sesión SSH, vuelve a entrar y comprueba que funciona:
docker run --rm hello-world
docker compose version
Hello from Docker!
...
Docker Compose version v2.39.2
Paso 2: Preparar la estructura de proyectos
Usarás /srv/projects como directorio común, con una carpeta por proyecto. Crea el directorio y dale la propiedad a tu usuario:
sudo mkdir -p /srv/projects
sudo chown $USER:$USER /srv/projects
mkdir -p /srv/projects/app1 /srv/projects/app2/src
A cada proyecto le asignarás un puerto local fijo. Anótalo en una tabla para no repetir puertos cuando añadas proyectos:
| Proyecto | Stack | Puerto local | Subdominio |
|---|---|---|---|
| app1 | Node.js 22 | 127.0.0.1:3001 | app1.dev.your_domain |
| app2 | PHP 8.3 + MariaDB 11 | 127.0.0.1:3002 | app2.dev.your_domain |
Importantepublica siempre los puertos de los contenedores en
127.0.0.1. Docker añade sus propias reglas de iptables y un puerto publicado como3001:3000queda abierto a Internet aunque UFW no lo permita. Con127.0.0.1:3001:3000solo Nginx, desde el propio servidor, puede llegar a él.
Paso 3: Crear el primer proyecto (Node.js)
Crea una aplicación mínima de Node.js. Abre el archivo:
nano /srv/projects/app1/server.js
Añade este contenido:
const http = require('node:http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ project: 'app1', node: process.version, path: req.url }));
});
server.listen(3000, '0.0.0.0', () => {
console.log('app1 escuchando en el puerto 3000');
});
Crea el archivo de Compose del proyecto:
nano /srv/projects/app1/compose.yaml
services:
web:
image: node:22-alpine
working_dir: /app
volumes:
- ./:/app
command: node --watch server.js
ports:
- "127.0.0.1:3001:3000"
restart: unless-stopped
El código se monta como volumen y node --watch reinicia el proceso cada vez que cambia un archivo, así que puedes editar en el servidor (o sincronizar desde tu equipo) y ver los cambios al momento.
Arranca el proyecto:
cd /srv/projects/app1
docker compose up -d
Comprueba que responde en su puerto local:
curl http://127.0.0.1:3001/
{"project":"app1","node":"v22.19.0","path":"/"}
Paso 4: Crear el segundo proyecto (PHP y MariaDB)
El segundo proyecto usa otro lenguaje y necesita base de datos. Las credenciales van en un archivo .env que Compose lee automáticamente y que nunca debe subirse al repositorio:
nano /srv/projects/app2/.env
MARIADB_DATABASE=app2
MARIADB_USER=app2
MARIADB_PASSWORD=your_strong_password
MARIADB_ROOT_PASSWORD=your_strong_root_password
Sustituye las dos contraseñas por valores propios y restringe los permisos del archivo:
chmod 600 /srv/projects/app2/.env
Crea una página PHP que se conecte a la base de datos:
nano /srv/projects/app2/src/index.php
<?php
$pdo = new PDO(
'mysql:host=db;dbname=' . getenv('MARIADB_DATABASE'),
getenv('MARIADB_USER'),
getenv('MARIADB_PASSWORD')
);
$version = $pdo->query('SELECT VERSION()')->fetchColumn();
echo "app2: PHP " . PHP_VERSION . ", MariaDB " . $version . "\n";
Como la imagen oficial de PHP no incluye la extensión pdo_mysql, crea un Dockerfile mínimo que la instale:
nano /srv/projects/app2/Dockerfile
FROM php:8.3-apache
RUN docker-php-ext-install pdo_mysql
Ahora el archivo de Compose con los dos servicios:
nano /srv/projects/app2/compose.yaml
services:
web:
build: .
volumes:
- ./src:/var/www/html
env_file: .env
ports:
- "127.0.0.1:3002:80"
depends_on:
- db
restart: unless-stopped
db:
image: mariadb:11
env_file: .env
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
volumes:
db_data:
La base de datos no publica ningún puerto: solo es accesible desde la red interna que Compose crea para este proyecto, donde el contenedor web la encuentra por el nombre db. Cada proyecto tiene su propia red, así que app1 no puede ver la base de datos de app2.
Construye y arranca el proyecto:
cd /srv/projects/app2
docker compose up -d --build
MariaDB tarda unos segundos en inicializarse la primera vez. Comprueba el estado y prueba la página:
docker compose ps
curl http://127.0.0.1:3002/
app2: PHP 8.3.26, MariaDB 11.8.3-MariaDB-ubu2404
Paso 5: Instalar Nginx como proxy inverso
Nginx recibirá todas las peticiones en los puertos 80 y 443 y las repartirá a cada proyecto según el subdominio. Instálalo y abre los puertos web en UFW:
sudo apt install nginx
sudo ufw allow 'Nginx Full'
Como es un entorno de desarrollo, no debe ser público. Protege todos los proyectos con autenticación básica. Instala la utilidad htpasswd y crea el primer usuario (te pedirá la contraseña):
sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd your_user
Para añadir más usuarios después, repite el comando sin -c (esa opción sobrescribe el archivo).
Crea un fragmento con la configuración de proxy común a todos los proyectos, para no repetirla en cada archivo:
sudo nano /etc/nginx/snippets/dev-proxy.conf
auth_basic "Entorno de desarrollo";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_http_version 1.1;
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;
# WebSockets (recarga en caliente de Vite, Next.js, etc.)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
La variable $connection_upgrade hay que definirla una vez a nivel http. Crea este archivo:
sudo nano /etc/nginx/conf.d/connection-upgrade.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Paso 6: Publicar cada proyecto en su subdominio
Crea un server block por proyecto. Empieza por app1:
sudo nano /etc/nginx/sites-available/app1.dev.your_domain
server {
listen 80;
listen [::]:80;
server_name app1.dev.your_domain;
location / {
include snippets/dev-proxy.conf;
proxy_pass http://127.0.0.1:3001;
}
}
Y el de app2, idéntico salvo el nombre y el puerto:
sudo nano /etc/nginx/sites-available/app2.dev.your_domain
server {
listen 80;
listen [::]:80;
server_name app2.dev.your_domain;
client_max_body_size 50m;
location / {
include snippets/dev-proxy.conf;
proxy_pass http://127.0.0.1:3002;
}
}
Activa los dos sitios, desactiva el sitio por defecto, comprueba la sintaxis y recarga Nginx:
sudo ln -s /etc/nginx/sites-available/app1.dev.your_domain /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/app2.dev.your_domain /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Prueba un proyecto sin credenciales y con ellas:
curl -I http://app1.dev.your_domain
curl -u your_user http://app1.dev.your_domain
HTTP/1.1 401 Unauthorized
...
{"project":"app1","node":"v22.19.0","path":"/"}
Paso 7: Activar HTTPS con Let's Encrypt
Instala Certbot con su plugin de Nginx:
sudo apt install certbot python3-certbot-nginx
Solicita un certificado para los dos subdominios. Certbot valida cada nombre por HTTP, modifica los server blocks para servir HTTPS y añade la redirección desde HTTP:
sudo certbot --nginx -d app1.dev.your_domain -d app2.dev.your_domain
Successfully deployed certificate for app1.dev.your_domain to /etc/nginx/sites-enabled/app1.dev.your_domain
Successfully deployed certificate for app2.dev.your_domain to /etc/nginx/sites-enabled/app2.dev.your_domain
Congratulations! You have successfully enabled HTTPS
El paquete instala un temporizador de systemd que renueva los certificados automáticamente. Comprueba que la renovación funciona:
sudo certbot renew --dry-run
Abre https://app1.dev.your_domain y https://app2.dev.your_domain en el navegador: te pedirá usuario y contraseña y después mostrará cada proyecto.
Paso 8: Añadir un proyecto nuevo
Con esta base, añadir un proyecto son siempre los mismos cuatro pasos:
- Crear
/srv/projects/nombrecon sucompose.yaml, publicando el siguiente puerto libre en127.0.0.1(por ejemplo127.0.0.1:3003). - Arrancarlo con
docker compose up -dy comprobarlo concurl http://127.0.0.1:3003/. - Copiar uno de los server blocks, cambiar
server_nameyproxy_pass, enlazarlo ensites-enabledy recargar Nginx. - Emitir su certificado con
sudo certbot --nginx -d nombre.dev.your_domain.
Para ver de un vistazo qué proyectos están en marcha y cuánto consumen:
docker compose ls
docker stats --no-stream
NAME STATUS CONFIG FILES
app1 running(1) /srv/projects/app1/compose.yaml
app2 running(2) /srv/projects/app2/compose.yaml
Si un proyecto no se usa durante un tiempo, páralo con docker compose stop desde su carpeta para liberar memoria; los datos de sus volúmenes se conservan.
Solución de problemas
502 Bad Gateway: Nginx no puede conectar con el contenedor. Comprueba que el proyecto está en marcha con docker compose ps en su carpeta, que el puerto de proxy_pass coincide con el publicado y revisa los logs con docker compose logs -f web.
SQLSTATE[HY000] [2002] Connection refused en app2: MariaDB aún se está inicializando o no ha arrancado. Revisa docker compose logs db. Si cambiaste las credenciales del .env después del primer arranque, ten en cuenta que MariaDB solo las aplica al crear el volumen: bórralo con docker compose down -v (se pierden los datos) y vuelve a arrancar.
Certbot falla con unauthorized o NXDOMAIN: el subdominio no resuelve a tu servidor. Comprueba el registro comodín con dig +short nombre.dev.your_domain y que el puerto 80 está abierto en UFW.
La recarga en caliente del frontend no funciona: falta la cabecera Upgrade para WebSockets. Asegúrate de que el server block incluye snippets/dev-proxy.conf y de que existe /etc/nginx/conf.d/connection-upgrade.conf.
Conclusión
Tienes un servidor de desarrollo en Ubuntu 24.04 donde cada proyecto corre aislado en su propio stack de Docker Compose, con sus versiones y su base de datos, y se publica en un subdominio propio con HTTPS y autenticación básica. Añadir un proyecto nuevo es cuestión de minutos y no afecta a los que ya están en marcha.
Como siguientes pasos, puedes:
- Conectar tu editor al servidor con la extensión Remote SSH de VS Code para trabajar directamente sobre
/srv/projects. - Programar copias de seguridad de los volúmenes de base de datos con
docker compose exec db mariadb-dump. - Automatizar el despliegue de cada rama desde tu sistema de CI con
docker compose up -d --buildpor SSH.
