Montaje de Servidor Web Linux: Guía Completa para Sysadmins Hispanohablantes
Llevo quince años montando servidores web en producción — desde Apache + mod_php en un Pentium 4 hasta clusters de Kubernetes en bare metal. La pregunta que me hacen seguido es: ¿qué pongo hoy para un sitio mediano (10k-500k visitas/mes) si quiero entender cada pieza sin pagar por un managed service? Después de probar nginx + php-fpm, Caddy, Traefik y OpenLiteSpeed, vuelvo siempre a la misma combinación simple. Esta guía documenta esa pila, con hardening real, no el checklist de marketing.
Si tienes un VPS Linux y quieres correr tu propio WordPress, Ghost, Nextcloud o app custom sin sorpresas, esta es la configuración. Si tu carga es mayor a 500k visitas/mes, el capítulo de load balancing al final aplica, pero ya entrás en territorio de Kubernetes o al menos Docker Swarm.
TL;DR — Lo que vas a tener al final
- Tiempo de setup: 1-2 horas para servidor limpio, 30-60 minutos para re-deploys con Ansible.
- Requisitos: VPS Linux con mínimo 1 GB RAM, 20 GB SSD, root, dominio con DNS apuntando.
- Resultado: nginx + PHP-FPM 8.3 + MariaDB + Let’s Encrypt, throughput ~5000 req/s en hardware modesto, tiempo de respuesta <50ms.
- Troubleshooting típico: permisos de /var/www, SELinux bloqueando nginx, certificados que no auto-renuevan. Todos diagnosticables con `nginx -T` + `journalctl -u nginx`.
Por qué importa montar tu propio servidor web en 2026 — y por qué Docker cambió las reglas
Hace diez años un servidor web Linux era una pila de paquetes instalados a mano sobre un sistema operativo pelado: Nginx desde los repos, PHP-FPM compilado con los módulos correctos, MariaDB atada a localhost y Let’s Encrypt como broche. Esa receta sigue funcionando, ya la cubrimos en la guía definitiva de LEMP clásico, y es lo correcto cuando tienes un solo sitio simple que atender.
El problema empieza cuando tu realidad es otra. Tres microservicios, una instancia de Nextcloud para el equipo, un blog en Ghost, una API en Node, certificados TLS distintos por dominio y cero ganas de rezar a Nginx para que el try_files no rompa WordPress otra vez. Instalar todo a mano en el mismo servidor lleva un día, se rompe en cada update del SO y deja archivos de configuración sin documentar que nadie quiere tocar meses después. Por eso, en 2026, el patrón dominante para sysadmins hispanohablantes que corren más de un sitio en el mismo VPS es otro: un único servidor base con Docker, un reverse proxy que termina TLS por tú y todas las aplicaciones definidas como código en un compose.yaml.
Esta guía va de eso: de un VPS recién provisionado a tres aplicaciones sirviendo HTTPS en tres dominios distintos, todo versionado en git, todo reproducible con un docker compose up -d. Sin Kubernetes, sin Swarm, sin paneles propietarios. Solo lo que corre nativo en cualquier Debian 12 o Ubuntu 24.04 LTS con 2 GB de RAM.
Qué vas a montar exactamente
El objetivo es un servidor web multi-app dockerizado, con Traefik como reverse proxy y gestor automático de certificados TLS. Los componentes viven en seis contenedores que comparten una red virtual privada y dos volúmenes persistentes para datos y configuración:
| Componente | Rol | Imagen / paquete |
|---|---|---|
| Traefik v3.1 | Reverse proxy + renovación TLS automática vía ACME DNS-01 | traefik:v3.1 |
| MariaDB 11 | Base de datos compartida para las apps | mariadb:11 |
| Nextcloud 29 | Nube privada (ficheros, calendario, contactos) | nextcloud:29-apache |
| Ghost 5 | Blog / CMS para contenido editorial | ghost:5 |
| API Node 22 | Microservicio de ejemplo en Express | node:22-alpine |
| Watchtower | Actualiza imágenes docker automáticamente | containrrr/watchtower |
El stack funciona sobre Debian 12 (bookworm), Debian 13 (trixie) o Ubuntu 22.04/24.04 LTS. En Rocky o RHEL los nombres cambian pero la arquitectura es la misma. La app de Node se incluye para que el ejemplo enseñe a integrar servicios que no están preempaquetados, que es el caso real cuando empiezas a desplegar tus propios proyectos.
Preparar el VPS: lo mínimo antes de tocar Docker
Docker necesita un kernel razonablemente moderno (5.x+), acceso a cgroups v2 y montaje de overlayfs. Cualquier Debian 12 u Ubuntu 24.04 ya viene con eso. Lo que SÍ tienes que hacer a mano es asegurar la base: usuario con sudo, SSH endurecido y firewall activo. Saltarse esto convierte al servidor en blanco de bots antes de terminar de escribir el compose.yaml.
El primer paso es actualizar el índice de paquetes y aplicar todos los parches pendientes. Esto baja el árbol de dependencias a una versión conocida y deja el servidor en un estado reproducible antes de empezar.
sudo apt update && sudo apt -y upgrade
sudo hostnamectl set-hostname web01.example.com
sudo timedatectl set-timezone America/Montreal
Si tu proveedor de cloud te da una IP pública dinámica, instala ddclient o el cliente de tu DNS dinámico. Para Hetzner, OVH y DigitalOcean lo más simple es apuntar los subdominios a la IP fija del servidor y olvidarse del drama del DNS dinámico. Si tu servidor está detrás de Cloudflare, recuerda que el proxy puede romper el desafío ACME DNS-01 si no abres el modo “DNS only” para el subdominio de validación, lo resolvemos más abajo con la variable CF_DNS_API_TOKEN.
Crear un usuario dedicado para administración es obligatorio antes de exponer el servidor a internet. Los bots empiezan a probar contraseñas de root en los primeros 60 segundos de tener IP pública. El patrón es exactamente el mismo que en cualquier guía de endurecimiento de SSH.
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo cp -r ~/.ssh /home/deploy/.ssh
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh && sudo chmod 600 /home/deploy/.ssh/*
Editá /etc/ssh/sshd_config, cambiá PermitRootLogin yes a PermitRootLogin no, agrega PasswordAuthentication no y reiniciá SSH con sudo systemctl restart ssh. Si te equivocas, la sesión actual sigue viva hasta que salgas; verifica siempre con una segunda conexión antes de cerrar la primera.
Docker + plugin compose: instalación limpia en un solo comando
Docker Engine viene en los repos oficiales de Debian y Ubuntu, pero arrastra dependencias viejas. La forma soportada por upstream es agregar el repo de Docker y dejar que el gestor de paquetes siga las versiones estables. El script oficial hace esto en una sola corrida.
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker deploy
newgrp docker
docker --version
docker compose version
El usermod -aG docker deploy permite que tu usuario no-root ejecute docker sin sudo. El newgrp docker activa el grupo en la sesión actual sin necesidad de deslogearte. Si docker compose version devuelve v2.x.x, el plugin de compose está correctamente instalado y listo para procesar tu compose.yaml.
Si el comando falla con permission denied al socket /var/run/docker.sock, casi siempre es porque el grupo docker todavía no está activo en la sesión. Solución rápida: logout y volver a entrar, o exec sudo -u deploy -- bash para abrir un shell nuevo donde el grupo ya esté aplicado.
Para que los contenedores sobrevivan reinicios del servidor, habilita el servicio de systemd:
sudo systemctl enable --now docker
sudo systemctl status docker
El status debería mostrar active (running). Si dice inactive (dead), revisa journalctl -u docker -n 50 para ver qué falla. En kernels sin cgroups v2 habilitados (típico en algunos VPS viejos), Docker inicia pero los contenedores fallan con failed to create shim; solución: editar /etc/default/grub, agregar GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1", correr sudo update-grub y reiniciar.
Traefik como reverse proxy: configuración que se explica sola
Traefik descubre contenedores por etiquetas de Docker y les emite un certificado TLS automáticamente sin tocar el server block de Nginx a mano. Es el reemplazo moderno de “configurar Nginx + Certbot + cron de renovación” para stacks dockerizados. La imagen oficial pesa menos de 50 MB y arranca en menos de 2 segundos.
El archivo de configuración estática vive en /opt/web/traefik/traefik.yml y define los entrypoints, el proveedor Docker y la integración con Let’s Encrypt vía desafío DNS-01. Usamos DNS-01 en vez de HTTP-01 porque queremos emitir wildcard certificates para no estar limitados a 5 dominios por semana cuando ACME nos rate-limita.
# /opt/web/traefik/traefik.yml
global:
checkNewVersion: false
sendAnonymousUsage: false
log:
level: INFO
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
network: web
certificatesResolvers:
letsencrypt:
acme:
email: [email protected]
storage: /letsencrypt/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
Este archivo le dice a Traefik: escuchá en los puertos 80 y 443, redirigí HTTP a HTTPS, descubrí contenedores en la red web solo cuando tengan la etiqueta traefik.enable=true y renová certificados vía Cloudflare DNS. La clave CF_DNS_API_TOKEN la pasamos como variable de entorno en el compose.yaml; Traefik la lee y la usa para crear registros TXT temporales que validan el dominio.
Para que el archivo de configuración viva en un volumen persistente, lo creamos con permisos correctos y lo copiamos. Si no, Docker lo monta como root:root y Traefik no puede escribir acme.json cuando renueva certificados.
sudo mkdir -p /opt/web/traefik/letsencrypt
sudo touch /opt/web/traefik/letsencrypt/acme.json
sudo chmod 600 /opt/web/traefik/letsencrypt/acme.json
sudo chown -R deploy:deploy /opt/web
El chmod 600 sobre acme.json es obligatorio: Traefik rechaza arrancar si el archivo tiene permisos abiertos. Si lo ves en logs como permission denied on acme.json, corregí los permisos con sudo chmod 600 y reiniciá el contenedor.
Compose file: las tres apps que se hablan entre sí
La magia del stack moderno vive en un único archivo compose.yaml declarativo. Cada servicio declara sus etiquetas de Traefik (qué dominio, qué router, qué TLS) y el proxy se configura solo cuando levantas el stack. Si pierdes el servidor, restauras todo con git clone && docker compose up -d en menos de diez minutos.
# /opt/web/compose.yaml
name: web
services:
traefik:
image: traefik:v3.1
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/letsencrypt:/letsencrypt
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
networks:
- web
command:
- --providers.docker=true
- --entrypoints.web.http.redirections.entryPoint.to=websecure
depends_on:
- mariadb
mariadb:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/mariadb_root
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD_FILE: /run/secrets/mariadb_nextcloud
volumes:
- mariadb-data:/var/lib/mysql
- ./secrets:/run/secrets:ro
networks:
- web
nextcloud:
image: nextcloud:29-apache
restart: unless-stopped
depends_on:
- mariadb
volumes:
- nextcloud-data:/var/www/html
environment:
POSTGRES_HOST: mariadb
NEXTCLOUD_DB_TYPE: mysql
NEXTCLOUD_DB_HOST: mariadb
NEXTCLOUD_DB_NAME: nextcloud
NEXTCLOUD_DB_USERNAME: nextcloud
NEXTCLOUD_DB_PASSWORD_FILE: /run/secrets/mariadb_nextcloud
NEXTCLOUD_TRUSTED_DOMAINS: cloud.example.com
labels:
- traefik.enable=true
- traefik.http.routers.nextcloud.rule=Host(`cloud.example.com`)
- traefik.http.routers.nextcloud.entrypoints=websecure
- traefik.http.routers.nextcloud.tls.certresolver=letsencrypt
- traefik.http.services.nextcloud.loadbalancer.server.port=80
networks:
- web
ghost:
image: ghost:5
restart: unless-stopped
depends_on:
- mariadb
environment:
database__client: mysql
database__connection__host: mariadb
database__connection__user: ghost
database__connection__password_FILE: /run/secrets/mariadb_ghost
database__connection__database: ghost
url: https://blog.example.com
volumes:
- ghost-data:/var/lib/ghost/content
labels:
- traefik.enable=true
- traefik.http.routers.ghost.rule=Host(`blog.example.com`)
- traefik.http.routers.ghost.entrypoints=websecure
- traefik.http.routers.ghost.tls.certresolver=letsencrypt
- traefik.http.services.ghost.loadbalancer.server.port=2368
networks:
- web
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --cleanup --schedule "0 0 4 * * *"
networks:
- web
volumes:
mariadb-data:
nextcloud-data:
ghost-data:
networks:
web:
driver: bridge
Cada servicio declara qué dominio sirve vía la etiqueta traefik.http.routers.<nombre>.rule=Host(`dominio`). Cuando docker compose up -d arranca el stack, Traefik lee las etiquetas, genera los routers, solicita los certificados TLS a Let’s Encrypt vía Cloudflare DNS y empieza a rutear tráfico. Sin tocar Nginx a mano. Sin editar sites-enabled. Sin correr certbot --nginx.
El truco de los secretos vive en el directorio /opt/web/secrets/: cada archivo contiene una sola contraseña y Docker lo monta en /run/secrets/ dentro del contenedor. La alternativa con environment: plano expone contraseñas en docker inspect y en cualquier git log; usar _FILE con Docker secrets es la forma estándar de manejar credenciales en producción.
sudo mkdir -p /opt/web/secrets
openssl rand -base64 32 | sudo tee /opt/web/secrets/mariadb_root
openssl rand -base64 32 | sudo tee /opt/web/secrets/mariadb_nextcloud
openssl rand -base64 32 | sudo tee /opt/web/secrets/mariadb_ghost
sudo chmod 600 /opt/web/secrets/*
sudo chown -R deploy:deploy /opt/web
El openssl rand -base64 32 genera 32 bytes aleatorios codificados en base64, suficiente para una contraseña que un humano nunca va a tipear y un atacante no va a adivinar. Si docker compose up tira permission denied on /run/secrets/..., revisa los permisos: el dueño debe ser deploy y los archivos 600.
Levantar todo y verificar
Con el compose.yaml listo, los secretos generados y Traefik configurado, levantar el stack entero es un solo comando:
cd /opt/web
echo "CF_DNS_API_TOKEN=tu-token-de-cloudflare" > .env
docker compose up -d
docker compose ps
docker compose logs traefik | head -30
El .env es leído por Docker Compose y nunca entra al control de versiones (agregalo a .gitignore). El docker compose ps debería listar los seis servicios con estado running o healthy. Los logs de Traefik muestran la emisión del primer certificado y los routers descubiertos; si ves level=error msg="Cannot start the provider, casi siempre es DNS o el token de Cloudflare mal copiado.
Para verificar desde tu laptop que HTTPS ya responde correctamente:
curl -I https://cloud.example.com
curl -I https://blog.example.com
El curl -I (HEAD) te devuelve los headers HTTP sin bajar el body. Un sitio bien configurado devuelve 200 OK, strict-transport-security activo y un server: nginx/1.27 o server: cloudflare (Traefik delega el front; el back-end real es lo que responde cada app). Si el certificado es válido, curl no tira ningún warning sobre TLS. Si dice SSL certificate verify ok, todo está conectado.
El comando docker compose logs traefik --tail=200 es el primer lugar donde mirar cuando algo falla. Errores típicos: certificate needs renewal (DNS no actualizó), router already exists (etiqueta duplicada), backend not found (el contenedor no se levantó). Todos tienen remediación clara y están documentados en doc.traefik.io.
Operación cotidiana: backups, actualizaciones y secretos rotando
Un servidor sin backups es una bomba de tiempo. Para MariaDB y los volúmenes de las apps, el patrón más simple es docker exec + mariadb-dump + tar czf sobre los volúmenes nombrados, programado con un systemd timer para evitar la dependencia de cron:
sudo tee /usr/local/bin/web-backup.sh < <'EOF'
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/web
TS=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p "$BACKUP_DIR/$TS"
docker compose -f /opt/web/compose.yaml exec -T mariadb
mariadb-dump --all-databases --single-transaction
| gzip > "$BACKUP_DIR/$TS/mariadb.sql.gz"
docker compose -f /opt/web/compose.yaml exec -T mariadb
sh -c 'tar czf - /var/lib/mysql' > "$BACKUP_DIR/$TS/mariadb-data.tgz"
for vol in nextcloud-data ghost-data; do
docker run --rm -v web_${vol}:/data -v "$BACKUP_DIR/$TS":/backup
alpine tar czf "/backup/${vol}.tgz" -C /data .
done
rsync -az --delete "$BACKUP_DIR/" [email protected]:/srv/web/
find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} ;
EOF
sudo chmod +x /usr/local/bin/web-backup.sh
El script se ejecuta cada noche vía un systemd timer; el --delete en rsync mantiene el destino sincronizado, y el find borra backups locales con más de 7 días. El destino backup.example.com puede ser un segundo VPS, un bucket B2 o tu NAS; lo importante es que esté fuera de la misma máquina que se está respaldando.
Para actualizaciones, Watchtower ya está corriendo como servicio dentro del stack. Cada noche a las 04:00 busca nuevas versiones de las imágenes que declaraste y reinicia los contenedores automáticamente. Si quieres evitar reinicios sorpresa en producción, agrega la etiqueta com.centurylinklabs.watchtower.enable=false al servicio que quieres congelar y deja Watchtower inactivo sobre ese contenedor.
La rotación de secretos es la pieza que casi nadie automatiza y la primera causa de incidentes en stacks viejos. Lo ideal es regenerar las contraseñas cada 90 días con el mismo openssl rand -base64 32, actualizar el archivo en /opt/web/secrets/ y correr docker compose up -d para que los contenedores lean el nuevo valor. Si la contraseña está embebida en una base de datos de aplicación (por ejemplo, un usuario de Nextcloud), también hay que ejecutar el ALTER USER correspondiente en MariaDB.
Cuándo NO usar este stack
Este patrón sirve para el 90% de los casos reales de un sysadmin LATAM: uno o dos VPS, entre tres y veinte microservicios, tráfico bajo-medio, presupuesto limitado. No es la respuesta correcta en tres escenarios puntuales.
El primero es cuando esperas tráfico serio y sostenido. Si estimas más de 1000 requests por segundo de forma continua, el cuello de botella aparece primero en MariaDB y segundo en la red interna entre Traefik y los backends. La salida es mover MariaDB a un servidor dedicado (o un RDS gestionado), repartir las apps en múltiples VPS detrás de un balanceador externo y considerar un cluster de Kubernetes. Eso queda fuera de “montar desde cero” pero es la dirección correcta cuando el servidor empieza a doler.
El segundo es si necesitas alta disponibilidad. Un solo VPS es un punto único de fallo: si el proveedor se cae o tu datacenter tiene un corte, todos los sitios se van al piso juntos. Para HA real necesitas al menos dos VPS en regiones distintas, MariaDB con réplica, balanceador externo tipo Cloudflare Load Balancer o HAProxy y un sistema de failover automático. Es otro orden de magnitud en complejidad.
El tercero es si tu equipo no quiere tocar Docker. La promesa “reproducible con un comando” se rompe si nadie entiende qué hace cada volumen ni por qué acme.json tiene permisos 600. Para esos casos, la receta clásica de LEMP con paquetes del sistema sigue siendo defendible y vale la pena quedarse con ella. La elección correcta es la que tu equipo puede mantener a las 3 AM, no la que tiene más features.
Resumen
Montar un servidor web Linux en 2026 sigue siendo una habilidad esencial para cualquier sysadmin, pero la forma correcta cambió. Hoy el patrón dominante es un VPS limpio con Docker Engine, un reverse proxy que termina TLS por tú como Traefik, y todas las aplicaciones definidas como código en un compose.yaml versionado en git. Con eso puedes servir HTTPS en una docena de dominios distintos, levantar una nueva app con un docker compose up -d y recuperar el stack entero en otra máquina en minutos.
Si lo que tienes es un solo sitio o un blog personal, el patrón clásico de LEMP sigue siendo más simple y no necesitas Docker. Pero en cuanto aparecen tres apps distintas o quieres que el setup sea reproducible entre tu laptop y el servidor de producción, este stack contenedor vale la pena el esfuerzo inicial. La inversión se paga sola la primera vez que tienes que migrar a otro proveedor o reconstruir después de un incidente.
Preguntas frecuentes
¿Por qué Montaje de Servidor Web Linux: Guía Completa para Sysadmins Hispanohablantes es relevante para sysadmins Linux?
Forma parte del flujo diario de administración. Los conceptos explicados se aplican directamente a servidores en producción y resuelven problemas reales que enfrentan sysadmins hispanohablantes.
¿Qué necesito para empezar con Montaje de Servidor Web Linux:?
Distribución Linux actualizada (Debian, Ubuntu, RHEL, Fedora o Arch), acceso root, y conexión a internet. Para producción: entorno de staging separado antes de aplicar cambios.
¿Cuáles son los errores más comunes con Montaje de Servidor Web Linux:?
Los tres más frecuentes: (1) saltar la lectura de logs, (2) aplicar cambios directos en producción sin probar, (3) olvidar permisos y ownership. La documentación y un plan de rollback siempre.
¿Cuándo usar Montaje de Servidor Web Linux: y cuándo una alternativa?
Esta solución es ideal para el caso descrito. Alternativas como Docker, Ansible o configuración manual pueden ser mejores según contexto: portabilidad, escala y reproducibilidad son los factores clave.
¿Cómo migro desde una configuración previa a Montaje de Servidor Web Linux:?
Documentar el estado actual, probar la nueva configuración en staging, validar funcionalmente, y aplicar en producción con rollback plan listo. Los posts de SoyLinux incluyen troubleshooting para los problemas más comunes.
Lee también
- Guía Fedora 44: Novedades, Instalación y Upgrade para Sysadmins Linux
- Configura VLANs en Linux con nmcli: Guía paso a paso para sysadmins
- Protege tu SSH: Guía esencial para configurar Fail2ban en Linux
- Guía Definitiva: Configura Kubernetes con kubeadm en Ubuntu para Sysadmins Linux
- Configura Prometheus y Grafana: Guía DevOps para SysAdmins Linux
