Qué vas a conseguir
Un servidor Linux con Docker donde las herramientas internas (un panel, un Grafana, un runner de Jenkins) corren como contenedores: se arrancan con un fichero, se actualizan cambiando una versión y sus datos sobreviven a los reinicios. Y la rutina de diagnóstico cuando uno falla.
Antes de empezar
- Un servidor Ubuntu o RHEL ya configurado (guías del Nivel 2), con 20 GB libres en
/var/lib/docker. - Saber qué puertos va a exponer cada herramienta: el firewall del servidor tiene que dejarlos pasar.
- Un lugar donde guardar los ficheros Compose bajo Git.
1. Instalar y comprobar
curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh...sudo usermod -aG docker ansible && newgrp dockerdocker version --format '{{.Server.Version}}'27.3.1docker run --rm hello-world | head -3Hello from Docker!This message shows that your installation appears to be working correctly.docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 1 0 13.3kB 13.3kB (100%)Containers 0 0 0B 0BLocal Volumes 0 0 0B 0BEl usuario en el grupo docker equivale a root en ese servidor: solo quien administra la máquina debe estar en él.
2. La primera herramienta con Compose
services: grafana: image: grafana/grafana-oss:11.3.0 container_name: grafana restart: unless-stopped ports: - "3000:3000" environment: GF_SERVER_ROOT_URL: https://tools.contoso.local/grafana/ GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin volumes: - grafana-data:/var/lib/grafana secrets: - grafana_admin healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 30s timeout: 5s retries: 3 volumes: grafana-data: secrets: grafana_admin: file: ./secrets/grafana_admin.txtTres decisiones que te ahorran problemas: la versión fija de la imagen (nunca latest en un servidor), los datos en un volumen con nombre (no dentro del contenedor) y la contraseña en un secreto desde fichero, que va al .gitignore.
mkdir -p ~/stacks/grafana/secrets && cd ~/stacks/grafanaopenssl rand -base64 24 > secrets/grafana_admin.txt && chmod 600 secrets/grafana_admin.txtecho "secrets/" >> .gitignoredocker compose up -d[+] Running 2/2 ✔ Volume "grafana_grafana-data" Created ✔ Container grafana Starteddocker compose psNAME IMAGE STATUS PORTSgrafana grafana/grafana-oss:11.3.0 Up 40 seconds (healthy) 0.0.0.0:3000->3000/tcpsudo firewall-cmd --add-port=3000/tcp --permanent && sudo firewall-cmd --reload3. Cuando un contenedor se cae
docker compose psNAME IMAGE STATUSgrafana grafana/grafana-oss:11.3.0 Restarting (1) 12 seconds agodocker compose logs --tail 30 grafanagrafana | Error: ✗ failed to connect to database: open /var/lib/grafana/grafana.db: permission denieddocker inspect grafana --format '{{.State.ExitCode}} {{.State.Error}} {{.RestartCount}}'1 7docker run --rm -v grafana_grafana-data:/data alpine ls -ln /data | head -3total 1032-rw-r----- 1 0 0 1056768 Oct 22 07:40 grafana.dbdocker run --rm -v grafana_grafana-data:/data alpine chown -R 472:0 /datadocker compose restart grafana && sleep 20 && docker compose ps --format '{{.Name}} {{.Status}}'grafana Up 20 seconds (healthy)El orden siempre es el mismo: ps (qué estado), logs --tail (qué dice), inspect (código de salida y reinicios). Un contenedor en Restarting con el mismo error en bucle no se arregla reiniciando: se arregla leyendo el log. Aquí el volumen había sido restaurado con root y Grafana corre como el usuario 472.
Si el problema es de red, docker exec -it grafana sh te mete dentro para hacer wget a lo que el contenedor no alcanza, y docker network inspect bridge enseña qué IP tiene cada uno.
4. Actualizar sin perder datos
docker run --rm -v grafana_grafana-data:/data -v $PWD/backup:/backup alpine tar czf /backup/grafana-data-2026-10-22.tgz -C /data .sed -i 's/grafana-oss:11.3.0/grafana-oss:11.3.1/' docker-compose.ymldocker compose pull && docker compose up -d[+] Running 1/1 ✔ Container grafana Starteddocker compose logs --tail 3 grafana | grep -i "http server listen"grafana | logger=http.server t=2026-10-22T08:01:12Z level=info msg="HTTP Server Listen" address=[::]:3000Copia del volumen, cambio de versión en el fichero, pull, up -d. Si la nueva versión falla, vuelves a la anterior con el mismo sed y, si hace falta, restauras el .tgz. El fichero Compose con el cambio de versión es el commit que va al ticket.
5. Limpieza mensual
docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 9 3 4.1GB 2.7GB (65%)Build Cache 41 0 1.2GB 1.2GBdocker image prune -a --filter "until=720h"docker builder prune -fdocker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 3 3 1.4GB 0B (0%)Nunca ejecutes docker volume prune en un servidor sin revisar antes docker volume ls: borra los volúmenes que ningún contenedor usa en ese momento, incluidos los de un servicio parado a propósito.
Siguientes pasos
- Pon un proxy inverso (Traefik o Caddy) delante de los contenedores para que todos salgan por 443 con certificado y sin abrir un puerto por herramienta.
- Mete
docker compose pull && up -den el playbook de Ansible: la actualización de las herramientas pasa a ser una tarea más del parcheo. - Con más de un servidor o con alta disponibilidad, el salto es Kubernetes (Nivel 4); hasta entonces, Compose en un servidor bien respaldado es suficiente para las herramientas internas.
¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.
What you'll get
A Linux server with Docker where internal tools (a dashboard, a Grafana, a Jenkins runner) run as containers: they start from one file, they update by changing a version, and their data survives restarts. Plus the diagnosis routine when one fails.
Before you start
- An Ubuntu or RHEL server already configured (Level 2 guides), with 20 GB free in
/var/lib/docker. - Know which ports each tool will expose: the server firewall has to let them through.
- A place to keep the Compose files under Git.
1. Install and check
curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh...sudo usermod -aG docker ansible && newgrp dockerdocker version --format '{{.Server.Version}}'27.3.1docker run --rm hello-world | head -3Hello from Docker!This message shows that your installation appears to be working correctly.docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 1 0 13.3kB 13.3kB (100%)Containers 0 0 0B 0BLocal Volumes 0 0 0B 0BA user in the docker group is effectively root on that server: only the people who administer the machine belong in it.
2. The first tool with Compose
services: grafana: image: grafana/grafana-oss:11.3.0 container_name: grafana restart: unless-stopped ports: - "3000:3000" environment: GF_SERVER_ROOT_URL: https://tools.contoso.local/grafana/ GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin volumes: - grafana-data:/var/lib/grafana secrets: - grafana_admin healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 30s timeout: 5s retries: 3 volumes: grafana-data: secrets: grafana_admin: file: ./secrets/grafana_admin.txtThree decisions that save you trouble: a pinned image version (never latest on a server), data in a named volume (not inside the container) and the password as a secret from a file, which goes in .gitignore.
mkdir -p ~/stacks/grafana/secrets && cd ~/stacks/grafanaopenssl rand -base64 24 > secrets/grafana_admin.txt && chmod 600 secrets/grafana_admin.txtecho "secrets/" >> .gitignoredocker compose up -d[+] Running 2/2 ✔ Volume "grafana_grafana-data" Created ✔ Container grafana Starteddocker compose psNAME IMAGE STATUS PORTSgrafana grafana/grafana-oss:11.3.0 Up 40 seconds (healthy) 0.0.0.0:3000->3000/tcpsudo firewall-cmd --add-port=3000/tcp --permanent && sudo firewall-cmd --reload3. When a container dies
docker compose psNAME IMAGE STATUSgrafana grafana/grafana-oss:11.3.0 Restarting (1) 12 seconds agodocker compose logs --tail 30 grafanagrafana | Error: ✗ failed to connect to database: open /var/lib/grafana/grafana.db: permission denieddocker inspect grafana --format '{{.State.ExitCode}} {{.State.Error}} {{.RestartCount}}'1 7docker run --rm -v grafana_grafana-data:/data alpine ls -ln /data | head -3total 1032-rw-r----- 1 0 0 1056768 Oct 22 07:40 grafana.dbdocker run --rm -v grafana_grafana-data:/data alpine chown -R 472:0 /datadocker compose restart grafana && sleep 20 && docker compose ps --format '{{.Name}} {{.Status}}'grafana Up 20 seconds (healthy)The order is always the same: ps (what state), logs --tail (what it says), inspect (exit code and restart count). A container in Restarting with the same error in a loop is not fixed by restarting: it is fixed by reading the log. Here the volume had been restored as root and Grafana runs as user 472.
If the problem is network, docker exec -it grafana sh gets you inside to wget whatever the container can't reach, and docker network inspect bridge shows which IP each one has.
4. Update without losing data
docker run --rm -v grafana_grafana-data:/data -v $PWD/backup:/backup alpine tar czf /backup/grafana-data-2026-10-22.tgz -C /data .sed -i 's/grafana-oss:11.3.0/grafana-oss:11.3.1/' docker-compose.ymldocker compose pull && docker compose up -d[+] Running 1/1 ✔ Container grafana Starteddocker compose logs --tail 3 grafana | grep -i "http server listen"grafana | logger=http.server t=2026-10-22T08:01:12Z level=info msg="HTTP Server Listen" address=[::]:3000Volume backup, version change in the file, pull, up -d. If the new version fails, you go back to the previous one with the same sed and, if needed, restore the .tgz. The Compose file with the version change is the commit that goes in the ticket.
5. Monthly clean-up
docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 9 3 4.1GB 2.7GB (65%)Build Cache 41 0 1.2GB 1.2GBdocker image prune -a --filter "until=720h"docker builder prune -fdocker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 3 3 1.4GB 0B (0%)Never run docker volume prune on a server without checking docker volume ls first: it deletes the volumes no container is using at that moment, including those of a service you stopped on purpose.
Next steps
- Put a reverse proxy (Traefik or Caddy) in front of the containers so everything comes out on 443 with a certificate, without opening one port per tool.
- Add
docker compose pull && up -dto the Ansible playbook: updating the tools becomes one more patching task. - With more than one server or with high availability, the jump is Kubernetes (Level 4); until then, Compose on a well backed-up server is enough for internal tools.
Is something missing, or different in your environment? Get in touch and I'll fix it.
Tech Career Lab · newsletter
¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.
Apúntate y llévate las 33 ideasSign up and get the 33 ideas →