Panel de control centralizado para un VPS pequeño con varios proyectos en Docker. En una sola
pantalla de Grafana se ve cómo está el servidor (CPU, RAM, disco), cuánto consume cada contenedor y si
cada servicio responde, gracias a Uptime Kuma. Todo se levanta con un docker compose up -d, detrás de
Nginx y con HTTPS.
Hoy vigila mi VPS de Contabo (roviradev.duckdns.org), que aloja:
- mosca-borracha: una simulación del cerebro de una Drosophila bajo los efectos del alcohol, con su consola web en tiempo real (FastAPI + WebSocket).
- Jeffrey: un bot de WhatsApp (Baileys + Kimi) que gestiona mi horario universitario, con transcripción de notas de voz local (faster-whisper).
English summary. A self-hosted monitoring stack for a small multi-project VPS: Grafana (single pane of glass), Prometheus + node-exporter (host CPU/RAM/disk), cAdvisor (per-container usage) and Uptime Kuma (is each service up?), behind Nginx on its own subdomain. Caddy on the host terminates TLS. Nothing listens on a public interface, internal networks have no Internet egress, the Docker socket is only exposed read-only through a filtering proxy, and dashboards and data sources are provisioned as code.
- Qué se ve en el panel
- Arquitectura
- Stack
- Decisiones de diseño
- Instalación
- Uptime Kuma
- Operación
- Estructura del repositorio
El dashboard VPS · roviradev es la página de inicio de Grafana y está en la carpeta VPS. Se
provisiona desde grafana/dashboards/vps-overview.json, así que
no hay que montarlo a mano.
| Sección | Paneles | Fuente |
|---|---|---|
| Servidor | tiempo encendido, CPU, RAM, disco, carga por núcleo, contenedores activos; CPU por tipo de uso (user, system, iowait, steal), memoria (en uso + caché bajo el total), ocupación de cada sistema de ficheros, lectura/escritura en disco | node-exporter |
| Servicios | estado de cada servicio a lo largo del tiempo (arriba / caído / pendiente / mantenimiento), estado actual, tiempo de respuesta, días que le quedan a cada certificado HTTPS | Uptime Kuma |
| Contenedores | CPU, memoria y tráfico de red de cada contenedor (mosca, bot, voz y el propio panel) | cAdvisor |
Uptime Kuma tiene además su propia interfaz en status.<dominio>: ahí se configuran los monitores y las
notificaciones (Telegram, correo, etc.).
flowchart TB
user(["Navegador"]) -->|"HTTPS :443"| caddy
subgraph vps["VPS · Ubuntu 24.04 · UFW: solo 22, 80 y 443"]
caddy["Caddy (host)<br/>TLS con Let's Encrypt"]
caddy -->|"roviradev.duckdns.org"| mosca["mosca<br/>127.0.0.1:8000"]
caddy -->|"panel.* y status.*<br/>127.0.0.1:8080"| nginx
subgraph panel["docker compose -p panel"]
nginx["Nginx"]
grafana["Grafana"]
kuma["Uptime Kuma"]
prometheus["Prometheus"]
node["node-exporter"]
cadvisor["cAdvisor"]
proxy["docker-socket-proxy<br/>solo lectura"]
end
bot["horario-bot + horario-stt<br/>(red horario-bot_default)"]
end
nginx -->|"panel.*"| grafana
nginx -->|"status.*"| kuma
grafana -->|PromQL| prometheus
prometheus -->|scrape| node
prometheus -->|scrape| cadvisor
prometheus -->|"scrape /metrics"| kuma
kuma -->|"¿contenedor en marcha?"| proxy
kuma -.->|"GET /health"| bot
kuma -.->|"HTTPS a las URLs públicas"| caddy
Recorrido de una petición. El navegador pide https://panel.roviradev.duckdns.org. Caddy, que ya
servía la mosca, pone el certificado y le pasa la petición a Nginx en 127.0.0.1:8080. Nginx mira el
Host y la reparte:
| Subdominio | Destino | Notas |
|---|---|---|
panel.<dominio> |
Grafana | login propio, límite de intentos en /login, WebSocket para Grafana Live |
status.<dominio> |
Uptime Kuma | la interfaz va por socket.io (WebSocket); /metrics bloqueado desde fuera |
cualquier otro Host |
nada | Nginx cierra la conexión sin responder (444) |
Redes Docker. Cada servicio está solo en las redes que necesita:
| Red | Tipo | Quién está | Para qué |
|---|---|---|---|
edge |
bridge, subred fija | nginx, grafana, uptime-kuma | tráfico web; Kuma sale a Internet desde aquí para comprobar las URLs públicas |
monitoring |
internal (sin salida a Internet) | prometheus, node-exporter, cadvisor, grafana, uptime-kuma | recogida de métricas |
docker-api |
internal | docker-socket-proxy, uptime-kuma | consultar el estado de los contenedores |
horario-bot_default |
externa (del bot) | uptime-kuma | comprobar el healthcheck interno del servicio de voz, que no publica puertos |
| Componente | Versión | Función |
|---|---|---|
| Caddy | la del host | HTTPS automático (Let's Encrypt) y proxy hacia Nginx |
| Nginx | 1.30.5 (alpine) | reverse proxy por subdominio, cabeceras de seguridad, límite de login |
| Grafana | 13.2.2 | panel visual único, provisionado como código |
| Prometheus | 3.14.0 | base de datos de métricas (30 días o 5 GB, lo que llegue antes) |
| node-exporter | 1.12.1 | métricas del servidor |
| cAdvisor | 0.60.6 | métricas por contenedor |
| Uptime Kuma | 2.5.5 | ¿responde cada servicio?, historial, notificaciones |
| docker-socket-proxy | 0.5.0 | acceso de solo lectura a la API de Docker para Uptime Kuma |
| Docker Compose | v5 | orquestación |
Todas las imágenes van con la versión fijada, así que una actualización nunca llega por sorpresa.
Caddy delante, Nginx detrás. El VPS ya tenía Caddy en los puertos 80 y 443 sirviendo la mosca con HTTPS automático. En vez de migrarlo todo a Nginx + certbot, que habría obligado a tocar un servicio que funciona (WebSocket, cabeceras de IP real...), Caddy solo pone el TLS y le pasa los subdominios del panel a Nginx. Nginx hace el enrutado y el endurecimiento. Así cada proyecto se despliega y se rompe por separado.
Nada escucha en una IP pública. Los únicos puertos publicados son 127.0.0.1:8080 (Nginx) y
127.0.0.1:3001 (Kuma, para entrar por túnel SSH). Esto importa porque Docker se salta UFW: un
ports: "3000:3000" quedaría abierto a Internet aunque el firewall diga lo contrario.
Prometheus y los exporters no ven Internet. La red monitoring es internal: true: ni Prometheus
ni node-exporter ni cAdvisor pueden hacer peticiones hacia fuera, y nada de fuera llega a ellos. La
interfaz de Prometheus no se publica: para explorar métricas está Explore en Grafana.
El socket de Docker, filtrado. Montar /var/run/docker.sock en Uptime Kuma equivale a darle root
en el host. En su lugar, Kuma habla con docker-socket-proxy, que solo permite GET /containers/...:
parar un contenedor o ver las imágenes devuelve 403. Además, el proxy está en su propia red interna
para que Grafana o Prometheus no puedan consultarlo, porque la API de contenedores expone las variables
de entorno de los demás servicios. cAdvisor sí monta el socket y va en modo privileged: lo necesita
para leer los cgroups y la red de cada contenedor. Es el componente con más privilegios del stack y por
eso no tiene salida a Internet ni puertos publicados.
Uptime Kuma en su propio subdominio. Uptime Kuma no admite servirse bajo una subruta como
panel.<dominio>/status, así que tiene el suyo. En DuckDNS cualquier subdominio de tu nombre ya resuelve
a tu IP, sin configurar nada.
node-exporter sin métricas de red. Para ver el tráfico real del host, node-exporter tendría que
correr con network_mode: host. Entonces Prometheus, dentro de una red Docker, no podría alcanzarlo sin
abrir un puerto en UFW. Como el objetivo era CPU, RAM y disco, desactivo los colectores de red
(mostrarían la interfaz del propio contenedor, que engaña) y el tráfico se mide por contenedor con
cAdvisor.
Todo como código. La fuente de datos y el dashboard se provisionan desde ficheros. Grafana no deja
guardar cambios encima (allowUiUpdates: false), así que el repo es siempre la verdad. La configuración
de Nginx es una plantilla y los dominios salen de .env.
Secretos fuera del repo y fuera del entorno. La contraseña de admin de Grafana se lee de un fichero
(GF_SECURITY_ADMIN_PASSWORD__FILE), no de una variable de entorno visible con docker inspect. La API
key de Kuma para Prometheus va por password_file. Los dos están en secrets/, con permisos 400 y a
nombre del usuario sin privilegios de cada contenedor.
Límites para convivir. Cada contenedor tiene mem_limit, cpus y no-new-privileges, y los logs
rotan (3 × 10 MB). El panel entero usa unos 550 MB de RAM y apenas CPU en reposo, en un VPS donde el
servicio de voz del bot puede llegar a 2 GB.
Grafana en español (GF_USERS_DEFAULT_LANGUAGE=es-ES) y sin llamadas a casa: sin analítica, sin
comprobación de actualizaciones y sin feed de noticias.
- Un servidor Linux con Docker y Docker Compose v2+.
- Caddy en el host, u otro proxy con TLS, que envíe los subdominios a
127.0.0.1:8080. - Dos subdominios que apunten al servidor: uno para Grafana y otro para Uptime Kuma.
- Si no usas el bot de WhatsApp, quita la red
botdeuptime-kumaendocker-compose.yml(y su definición abajo), porque Compose exige que las redes externas existan.
git clone https://github.com/Alvaro-Rovira/vps-control-panel.git /opt/panel
cd /opt/panel
./scripts/init-secrets.sh # genera la contraseña de Grafana y crea .env desde .env.example
nano .env # dominios, zona horaria, retención...init-secrets.sh nunca sobrescribe un secreto que ya exista.
docker compose -p panel up -d
docker compose -p panel ps # todo "Up"; nginx, cadvisor y uptime-kuma, "healthy"Si prefieres desplegar desde tu ordenador, ./scripts/deploy.sh root@IP sube el proyecto con rsync
(sin tocar .env ni secrets/) y hace el up -d.
Añade a /etc/caddy/Caddyfile el bloque de caddy/Caddyfile.snippet con tus
dominios y recarga:
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddyDeja fuera
status.<dominio>hasta completar el paso 1 de Uptime Kuma. La cuenta de administrador de Kuma se la queda quien entra primero, y un certificado nuevo aparece en los registros públicos de Certificate Transparency, que los bots vigilan para encontrar instalaciones sin configurar.
Usuario admin (o el de GRAFANA_ADMIN_USER). La contraseña está en el servidor:
cat /opt/panel/secrets/grafana_admin_passwordKuma solo escucha en 127.0.0.1:3001 del servidor. Desde tu ordenador:
ssh -L 3001:127.0.0.1:3001 root@IP_DEL_VPSAbre http://localhost:3001, crea el usuario administrador y, después, añade status.<dominio> a
Caddy (paso 3 de la instalación).
Settings → Docker Hosts → Setup Docker Host: tipo TCP / HTTP, dirección tcp://docker-socket-proxy:2375.
Los que uso en este VPS:
| Monitor | Tipo | Configuración |
|---|---|---|
| mosca · web | HTTP(s) | https://roviradev.duckdns.org/, códigos aceptados: 404. La consola exige un enlace con token y sin él responde 404 a todo. Si la app cae, Caddy devuelve 502, así que 404 significa «funciona». |
| mosca · contenedor | Docker Container | mosca |
| Jeffrey · bot | Docker Container | horario-bot (el bot no publica ningún puerto) |
| Jeffrey · voz | HTTP(s) | http://horario-stt:9000/health (por la red interna del bot) |
| Panel · Grafana | HTTP(s) | https://panel.roviradev.duckdns.org/api/health |
Para que la sección Servicios del dashboard tenga datos, Prometheus necesita leer el /metrics de
Kuma:
-
En Kuma: Settings → API Keys → Add API Key. Copia la clave (empieza por
uk). -
En el servidor, guárdala. El script la pide sin mostrarla en pantalla ni dejarla en el historial:
/opt/panel/scripts/set-kuma-api-key.sh
En unos 30 segundos el target uptime-kuma pasa a up y el estado de cada servicio aparece en Grafana.
cd /opt/panel
docker compose -p panel ps # estado
docker compose -p panel logs -f grafana # logs de un servicio
docker compose -p panel pull && docker compose -p panel up -d # tras cambiar versiones en el compose-
Cambiar el dashboard. Edita
grafana/dashboards/vps-overview.json(o expórtalo como JSON desde Grafana) y despliega: Grafana lo recarga en menos de un minuto. -
Copias de seguridad. Lo único con estado son los volúmenes
panel_grafana-data,panel_prometheus-dataypanel_kuma-data. Lo más valioso es el de Kuma, con los monitores y su historial:docker run --rm -v panel_kuma-data:/data -v "$PWD":/backup alpine tar czf /backup/kuma-$(date +%F).tgz -C /data .
-
Regenerar las capturas del README.
RANGE=6h ./scripts/screenshots.shlevanta un Grafana temporal de solo lectura en el VPS (sin usar la cuenta de admin), hace las capturas con Playwright y lo borra todo al terminar. -
Desinstalar.
docker compose -p panel down(añade-vpara borrar también los datos) y quita el bloque del panel del Caddyfile.
.
├── docker-compose.yml # los 7 servicios, redes, volúmenes y secretos
├── .env.example # dominios, zona horaria, retención, red del bot
├── nginx/
│ ├── templates/panel.conf.template # vhosts de Grafana y Kuma (envsubst al arrancar)
│ └── default.conf # vacío: anula la página de bienvenida de la imagen
├── prometheus/prometheus.yml # qué se recoge y cada cuánto
├── grafana/
│ ├── provisioning/datasources/ # Prometheus como fuente de datos
│ ├── provisioning/dashboards/ # dónde están los dashboards
│ └── dashboards/vps-overview.json # el panel
├── caddy/Caddyfile.snippet # lo que hay que añadir al Caddy del host
├── scripts/
│ ├── init-secrets.sh # contraseña de Grafana, .env (en el servidor)
│ ├── set-kuma-api-key.sh # guarda la API key de Kuma (en el servidor)
│ ├── deploy.sh # rsync + compose up (desde tu ordenador)
│ └── screenshots.sh # capturas del README
├── secrets/ # (ignorado por git) grafana_admin_password, kuma_api_key
└── docs/screenshots/

