Te mentiría si te dijera que todos mis contenedores han estado siempre bien configurados. La realidad es que más de una vez me he llevado un susto al darme cuenta de que un contenedor tenía más permisos de los que debería, o que el sistema de archivos era escribible sin necesidad. Y claro, un contenedor mal configurado es una puerta abierta a tu sistema. Un servicio que corre con demasiados privilegios, un sistema de archivos escribible donde no debería, un perfil de capacidades Linux demasiado permisivo… cada pequeño descuido es un vector de ataque que alguien, tarde o temprano, puede explotar. Por eso he decidido compartir contigo cómo endurecer tus contenedores como si fueran una fortaleza: sistema de archivos de solo lectura, tmpfs para partes efímeras, prohibición de nuevos privilegios, capacidades Linux mínimas, perfiles seccomp a medida, y etiquetado SELinux/AppArmor. Después veremos cómo llevarlo a producción con un despliegue rootless completo, Quadlets para servicios productivos, YADM para gestionar tus configuraciones como dotfiles, backup y restauración, monitoring con alertas, y actualizaciones automáticas seguras. Y todo esto lo verás en acción con el ejemplo completo: la migración real de atareao.es de Docker a Podman, documentada paso a paso como caso práctico.
Hardening en profundidad
El hardening no es una característica individual. Es una estrategia de capas. Menudo fenómeno estoy hecho, pero después de varios sustos he aprendido a base de prueba y error que cada capa que añades reduce la superficie de ataque y dificulta que un hipotético atacante pueda escalar privilegios o moverse lateralmente dentro de tu sistema. Vamos a ver cada capa por separado, y luego las combinaremos todas juntas.
Read-only rootfs: el sistema de archivos inmutable
El principio es sencillo: si un contenedor no necesita escribir en su sistema de archivos, no debería poder hacerlo. La mayoría de las aplicaciones solo necesitan escribir en directorios específicos: /tmp, /var/run, /var/lib/something. El resto del sistema de archivos, como binarios, librerías o configuraciones, debería ser de solo lectura.
Con Podman, activar el rootfs de solo lectura es tan simple como añadir --read-only:
podman run -d --name web-seguro --read-only nginx:alpine
Si el contenedor intenta escribir en cualquier lugar del sistema de archivos que no tenga un tmpfs montado, recibirá un error. Esto protege contra:
- Modificaciones maliciosas: un atacante no puede sobreescribir binarios del sistema.
- Malas prácticas: aplicaciones que escriben logs en
/var/logen lugar de stdout. - Persistencia accidental: contenedores efímeros que no deberían guardar estado.
Pero ojo, porque muchas aplicaciones necesitan escribir en algún sitio. Ahí entra en juego la segunda capa.
Tmpfs: espacio escribible pero volátil
Un tmpfs es un sistema de archivos temporal que reside en memoria RAM. Todo lo que se escribe en un tmpfs desaparece cuando el contenedor se detiene. Es perfecto para directorios que la aplicación necesita para funcionar, pero cuyo contenido no debe persistir.
Combinado con --read-only, los tmpfs son la combinación ganadora:
podman run -d --name web-seguro \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=64M \
--tmpfs /var/run:noexec,nosuid,size=32M \
--tmpfs /var/cache/nginx:noexec,nosuid,size=16M \
nginx:alpine
Fíjate en las opciones de montaje:
noexec: no se pueden ejecutar binarios desde este tmpfs. Impide que un atacante suba un script malicioso y lo ejecute.nosuid: ignora el bit setuid. Evita escalada de privilegios.size=64M: limita el tamaño máximo. Evita ataques de agotamiento de RAM.
Importante: cuando usas --read-only y no montas tmpfs para los directorios que el contenedor necesita escribir, el contenedor fallará al arrancar. Siempre verifica qué directorios necesita tu aplicación y créales tmpfs.
En Quadlets, se configura así:
[Container]
Image=docker.io/library/nginx:alpine
ContainerName=web-produccion
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=64M
TmpFs=/var/run:noexec,nosuid,size=32M
TmpFs=/var/cache/nginx:noexec,nosuid,size=16M
No-new-privileges: cortando la escalada
El flag --security-opt=no-new-privileges impide que el contenedor, o cualquier proceso dentro de él, gane nuevos privilegios. Esto bloquea cualquier intento de escalada mediante binarios setuid, setgid, o capacidades.
Es una barrera simple pero increíblemente efectiva:
podman run -d --name web-seguro \
--security-opt=no-new-privileges \
nginx:alpine
Si alguien dentro del contenedor ejecutara sudo, su, o cualquier binario con setuid, el kernel simplemente ignoraría el bit de privilegio. El proceso se ejecutaría con los permisos que ya tiene, sin ganar ninguno nuevo.
En Quadlet:
[Container]
Image=docker.io/library/nginx:alpine
SecurityLabel=no-new-privileges
Capabilities: el principio de mínimo privilegio
Las capabilities de Linux son unidades atómicas de privilegio. En lugar de dar a un proceso todos los permisos de root o ninguno, puedes conceder capacidades muy específicas.
Por defecto, Podman otorga un conjunto razonable de capacidades a los contenedores. Pero razonable no es lo mismo que mínimo necesario. La regla de oro es: dropea todas y añade solo las que necesites.
¿Y sabes qué? Al principio le tenía manía a esto de las capabilities. Me parecía una complicación innecesaria, otro nivel de abstracción que aprender. Pero después de un par de sustos con contenedores que tenían más permisos de la cuenta, me di cuenta de que es una de las capas de seguridad más efectivas que puedes activar. Y lo mejor: una vez que entiendes el patrón, se aplica igual en todos los contenedores.
podman run -d --name web-seguro \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--cap-add=CHOWN \
--cap-add=SETGID \
--cap-add=SETUID \
nginx:alpine
NET_BIND_SERVICE: permite enlazar a puertos privilegiados (<1024). Imprescindible para Nginx en puerto 80/443.CHOWN: permite cambiar el propietario de archivos.SETGIDySETUID: permiten cambiar GID/UID, necesario para que Nginx pueda soltar privilegios a su usuario nobody.DAC_OVERRIDE: permite saltarse comprobaciones de permisos de lectura/escritura/ejecución. Es peligrosa; evítala si puedes.
Para una API en Python, las capacidades necesarias son aún menores:
podman run -d --name api-segura \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--cap-add=NET_RAW \
python:3.12-slim python app.py
En Quadlets:
[Container]
Image=docker.io/library/nginx:alpine
ContainerName=web-produccion
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
AddCapability=CHOWN
AddCapability=SETGID
AddCapability=SETUID
Puedes listar todas las capacidades disponibles con:
capsh --print
O consultar la documentación de Podman:
podman run --help | grep -A 20 cap-add
Seccomp: filtrando syscalls
Seccomp (Secure Computing Mode) permite crear listas blancas o negras de llamadas al sistema (syscalls). Un contenedor solo puede hacer las syscalls que tú autorices explícitamente.
Podman incluye un perfil seccomp por defecto bastante razonable, pero puedes personalizarlo al máximo. Veamos cómo crear un perfil seccomp para una API web sencilla:
Creamos el archivo seccomp-api.json:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"accept", "accept4", "access", "arch_prctl", "bind",
"brk", "capget", "capset", "chdir", "chmod", "chown",
"clock_gettime", "clone", "close", "connect", "copy_file_range",
"creat", "dup", "dup2", "dup3", "epoll_create1",
"epoll_ctl", "epoll_pwait", "epoll_wait", "eventfd2",
"execve", "exit", "exit_group", "faccessat", "fadvise64",
"fallocate", "fchdir", "fchmod", "fchmodat", "fchown",
"fchownat", "fcntl", "fdatasync", "fgetxattr", "flistxattr",
"flock", "fremovexattr", "fsetxattr", "fstat", "fstatfs",
"fsync", "ftruncate", "futex", "getdents64", "getegid",
"geteuid", "getgid", "getpeername", "getpgrp", "getpid",
"getppid", "getrandom", "getresgid", "getresuid", "getrlimit",
"getrusage", "getsockname", "getsockopt", "gettid", "gettimeofday",
"getuid", "ioctl", "ioprio_get", "ipc", "listen",
"lseek", "lstat", "madvise", "mkdir", "mkdirat",
"mlock", "mlock2", "mmap", "mmap_legacy", "mprotect",
"mremap", "munlock", "munmap", "nanosleep", "newfstatat",
"open", "openat", "pause", "personality", "pipe",
"pipe2", "poll", "ppoll", "prctl", "pread64",
"preadv", "prlimit64", "pselect6", "pwrite64", "pwritev",
"read", "readlink", "readlinkat", "recvfrom", "recvmmsg",
"recvmsg", "rename", "renameat", "renameat2", "rmdir",
"rt_sigaction", "rt_sigpending", "rt_sigprocmask", "rt_sigreturn",
"rt_sigsuspend", "rt_sigtimedwait", "sched_getaffinity",
"sched_getattr", "sched_getparam", "sched_getscheduler",
"sched_yield", "select", "sendfile", "sendmmsg", "sendmsg",
"sendto", "set_robust_list", "set_tid_address", "setgid",
"setgroups", "setpgid", "setresgid", "setresuid", "setrlimit",
"setsid", "setsockopt", "setuid", "shutdown", "sigaltstack",
"socket", "socketpair", "splice", "stat", "statfs",
"symlink", "symlinkat", "sync", "sync_file_range",
"sysinfo", "tee", "tgkill", "time", "timer_create",
"timer_delete", "timer_settime", "timerfd_create",
"timerfd_gettime", "timerfd_settime", "truncate",
"umask", "uname", "unlink", "unlinkat", "unshare",
"utimensat", "vmsplice", "wait4", "waitid", "write",
"writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
La clave "defaultAction": "SCMP_ACT_ERRNO" significa que cualquier syscall no listada será bloqueada y devolverá un error. Esto es el enfoque de máxima seguridad: lista blanca.
Para usarlo:
podman run -d --name api-segura \
--security-opt seccomp=./seccomp-api.json \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
python:3.12-slim python app.py
En Quadlet:
[Container]
Image=python:3.12-slim
ContainerName=api-segura
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
SecurityLabel=seccomp=/home/usuario/seccomp-api.json
Nota importante 💡: crear un perfil seccomp desde cero requiere paciencia. La primera vez que arranques tu aplicación con el perfil restrictivo, probablemente fallen syscalls que no habías contemplado. Usa audit2allow o herramientas similares para identificar las syscalls que necesita tu aplicación. Otra opción es arrancar el contenedor sin perfil restrictivo, monitorizar las syscalls con strace y luego construir el perfil a partir de ahí:
# Arrancar sin restricciones y capturar syscalls
podman run --rm --security-opt seccomp=unconfined mi-app &
strace -p $(podman inspect -l --format '{{.State.Pid}}') -c -o syscalls.txt
AppArmor y SELinux: control de acceso obligatorio
Tanto AppArmor como SELinux son mecanismos de MAC (Mandatory Access Control). Van un paso más allá de las capabilities: permiten controlar qué archivos, redes y capacidades puede usar un proceso, incluso si el propietario del archivo lo permitiría.
Podman los soporta de serie. En sistemas con AppArmor (Debian, Ubuntu), Podman carga automáticamente un perfil por defecto. En sistemas con SELinux (Fedora, RHEL, CentOS), Podman aplica etiquetas de seguridad automáticamente.
AppArmor
Para ver el perfil activo:
podman run --rm alpine cat /proc/1/attr/current
Si quieres desactivarlo (no recomendado en producción):
podman run --rm --security-opt apparmor=unconfined alpine
Para cargar un perfil personalizado, primero creas el perfil:
# /etc/apparmor.d/containerd/mi-perfil
#include <tunables/global>
profile mi-perfil flags=(attach_disconnected) {
#include <abstractions/base>
# Permitir networking
network inet tcp,
network inet udp,
network inet6 tcp,
network inet6 udp,
# Directorios necesarios
/tmp/** rw,
/var/run/** rw,
/usr/share/nginx/** r,
# Denegar todo lo demás
deny /** w,
}
Lo cargas con sudo apparmor_parser -r /etc/apparmor.d/containerd/mi-perfil, y luego lo usas en Podman:
podman run -d --name web-seguro \
--security-opt apparmor=mi-perfil \
nginx:alpine
SELinux
En sistemas con SELinux, Podman añade automáticamente la opción :Z a los volúmenes montados para reetiquetarlos correctamente. Esto es importante porque sin el reetiquetado, el contenedor no podría leer los archivos.
# La opción :Z reetiqueta el volumen para que SELinux permita el acceso
podman run -d --name web -v ./html:/usr/share/nginx/html:Z nginx:alpine
Si ves errores de Permission denied en los logs del contenedor y estás en Fedora o RHEL, probablemente sea SELinux. Soluciones:
- Usa
:Z(reetiqueta el volumen para que el contenedor pueda acceder). - Usa
:z(comparte el volumen entre múltiples contenedores). - Cambia el contexto SELinux con
chcon. - Temporalmente:
sudo setenforce 0(solo para depuración, no en producción).
En Quadlets:
[Container]
Image=docker.io/library/nginx:alpine
Volume=./html:/usr/share/nginx/html:Z
SecurityLabel=type:container_t
Combinando todas las capas
El verdadero poder del hardening está en combinar todas las capas. Veamos un ejemplo completo:
podman run -d --name api-hardened \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=64M \
--tmpfs /var/run:noexec,nosuid,size=32M \
--tmpfs /var/cache:noexec,nosuid,size=32M \
--security-opt=no-new-privileges \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--cap-add=NET_RAW \
--security-opt seccomp=./seccomp-api.json \
--security-opt apparmor=mi-perfil-api \
--user 1000:1000 \
python:3.12-slim
Este contenedor es casi inexpugnable 🔥:
- No puede escribir en el sistema de archivos (read-only).
- Solo puede escribir en tmpfs específicos y limitados en tamaño.
- No puede escalar privilegios (no-new-privileges).
- Solo tiene dos capacidades Linux: enlazar a puertos y raw sockets.
- Solo puede hacer las syscalls de la lista blanca (seccomp).
- Está restringido por AppArmor a nivel de archivos y red.
- Se ejecuta como un usuario no privilegiado dentro del contenedor.
Si un atacante lograra colarse dentro, estaría en una jaula de la que es casi imposible escapar.
Vale, ya tenemos clara la teoría del hardening. Ahora veamos cómo se aplica en la práctica con un despliegue rootless completo.
Despliegue rootless completo
Ya sabes que Podman es rootless por defecto. Pero en producción necesitas ir más allá: necesitas que los contenedores sobrevivan a cierres de sesión, que arranquen automáticamente al encender el servidor, y que se integren perfectamente con systemd.
Linger: contenedores que sobreviven al logout
Cuando cierras sesión (SSH, terminal física), systemd mata todos los servicios de usuario. Para que tus contenedores rootless sigan vivos, necesitas activar linger:
# Activar linger para tu usuario
sudo loginctl enable-linger $USER
# Verificar
loginctl show-user $USER | grep Linger
A partir de ese momento, los servicios systemd de tu usuario arrancarán automáticamente al iniciar el sistema y sobrevivirán al cierre de sesión.
Puertos privilegiados rootless
Los puertos menores a 1024 (80, 443, etc.) son privilegiados. Un usuario normal no puede enlazar a ellos. Tienes tres opciones:
1. Usar un proxy reverso interno (recomendado): ejecuta un proxy como Caddy o Traefik en el sistema (como root o con capabilities adecuadas) que redirija tráfico del puerto 80/443 a puertos altos de tus contenedores rootless.
2. sysctl net.ipv4.ip_unprivileged_port_start: baja el límite de puerto privilegiado:
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee -a /etc/sysctl.d/99-usu-privileged-ports.conf
3. Asignar la capacidad CAP_NET_BIND_SERVICE en el Quadlet y usar iptables/nginx en el host para redirigir. Esta es la solución más elegante si tu contenedor tiene la capacidad adecuada:
[Container]
AddCapability=NET_BIND_SERVICE
Configuración de red rootless
Podman rootless usa por defecto slirp4netns o pasta para proporcionar conectividad de red a los contenedores. Para producción, considera estas configuraciones:
Red puente (recomendada para producción):
podman network create produccion-net
podman run -d --network produccion-net --name api nginx:alpine
Nota: el backend de red pasta (más rápido que slirp4netns) no se configura como driver de red, sino en containers.conf. Si estás en Podman 5+ y tu kernel lo soporta (5.19+), pasta se usa automáticamente para contenedores rootless. Puedes forzarlo editando ~/.config/containers/containers.conf:
Red con puertos mapeados:
podman run -d -p 8080:80 --name web nginx:alpine
En Quadlets:
[Container]
Network=produccion-net
PublishPort=8080:80
Importante: la red pasta es significativamente más rápida que slirp4netns porque usa características del kernel más modernas. Si estás en Podman 5+ y tu kernel lo soporta (5.19+), pasta se usa automáticamente.
Quadlets rootless: tu plantilla de producción
Aquí tienes la plantilla definitiva para un Quadlet rootless con todas las capas de hardening:
~/.config/containers/systemd/mi-app.container
[Unit]
Description=Mi aplicación en producción (hardened)
Documentation=https://atareao.es/tutorial/podman/
After=network-online.target
Wants=network-online.target
Requires=mi-app-data.volume
After=mi-app-data.volume
BindsTo=mi-app.network
After=mi-app.network
[Container]
Image=docker.io/miusuario/mi-app:stable
ContainerName=mi-app
PublishPort=8443:8443
Environment=ENV=production
EnvironmentFile=%E/mi-app/env.conf
Volume=mi-app-data.volume:/data:Z
Network=mi-app.network
NetworkAlias=app
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=64M
TmpFs=/var/run:noexec,nosuid,size=32M
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
AddCapability=CHOWN
AddCapability=SETGID
AddCapability=SETUID
SecurityLabel=no-new-privileges
SecurityLabel=seccomp=/home/miusuario/seccomp/perfil-app.json
Secret=db-password,type=env,target=DB_PASSWORD
Secret=api-key,type=file,target=/run/secrets/api-key
AutoUpdate=registry
HealthCmd=curl -f http://localhost:8443/health
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=60s
HealthTimeout=10s
LogDriver=journald
Label=app=mi-app
Label=env=production
[Service]
Restart=always
RestartSec=10s
TimeoutStartSec=300
TimeoutStopSec=30
[Install]
WantedBy=default.target
Y el volumen y la red correspondientes:
~/.config/containers/systemd/mi-app-data.volume
[Volume]
~/.config/containers/systemd/mi-app.network
[Network]
Subnet=10.89.0.0/24
Para activarlo:
systemctl --user daemon-reload
systemctl --user start mi-app
systemctl --user enable mi-app
systemctl --user status mi-app
journalctl --user -u mi-app -f
Hasta aquí el despliegue rootless. Ahora toca asegurarnos de que todas estas configuraciones no se pierdan, y para eso nada mejor que YADM.
YADM para gestión de dotfiles y configs
Tus Quadlets, tus perfiles seccomp, tus scripts de backup, tus configuraciones de Podman… todo eso son dotfiles, archivos de configuración que definen cómo funciona tu sistema. Perderlos sería un desastre.
YADM (Yet Another Dotfiles Manager) es una herramienta que convierte tu directorio ~ en un repositorio Git, permitiéndote versionar, sincronizar y restaurar todas tus configuraciones.
Instalación de YADM
# Debian/Ubuntu
sudo apt install yadm
# Fedora
sudo dnf install yadm
# Arch Linux
sudo pacman -S yadm
# Desde GitHub (si no está en los repos)
curl -fsSL https://raw.githubusercontent.com/TheLocehiliosan/yadm/master/yadm > /tmp/yadm
chmod +x /tmp/yadm
/tmp/yadm clone <tu-repo>
Inicializar YADM con tus configs de Podman
# Inicializar YADM
yadm init
# Añadir repositorio remoto (GitHub, GitLab, etc.)
yadm remote add origin git@github.com:tusuario/dotfiles.git
# Añadir configuraciones de Podman
yadm add ~/.config/containers/
yadm add ~/.config/systemd/
yadm add ~/.local/share/containers/storage/seccomp/
# Añadir scripts de backup, monitoring, etc.
yadm add ~/.local/bin/backup-podman.sh
yadm add ~/.local/bin/monitor-podman.sh
# Commit y push
yadm commit -m "Añadir configuraciones de Podman en producción"
yadm push
Estructura recomendada para dotfiles de Podman
Así deberías organizar tus configuraciones dentro de YADM:
~/.config/containers/
├── systemd/
│ ├── mi-app.container
│ ├── mi-app.volume
│ ├── mi-app.network
│ ├── traefik.container
│ ├── traefik.volume
│ ├── postgres.container
│ └── postgres.volume
├── seccomp/
│ ├── perfil-api.json
│ └── perfil-web.json
├── podman-policy.json
└── storage.conf
~/.local/bin/
├── backup-podman.sh
├── restore-podman.sh
├── monitor-podman.sh
└── deploy-all.sh
~/.config/systemd/user/
├── podman-auto-update.service.d/
│ └── notify.conf
└── mi-backup.service
Restaurar en un servidor nuevo
Lo mejor de YADM es que restaurar todo tu entorno de Podman en un servidor nuevo es cuestión de segundos:
# En el servidor nuevo
sudo apt install podman yadm
yadm clone git@github.com:tusuario/dotfiles.git
# Activar linger
sudo loginctl enable-linger $USER
# Recargar Quadlets
systemctl --user daemon-reload
# Arrancar todos los servicios
systemctl --user start mi-app traefik postgres
# Habilitar auto-arranque
systemctl --user enable mi-app traefik postgres
# Activar auto-update
systemctl --user enable --now podman-auto-update.timer
Tu servidor está exactamente igual que el original en menos de 5 minutos.
Con las configuraciones versionadas y seguras, toca ver cómo se aplica todo esto en un caso real. Y qué mejor que la migración de atareao.es.
Migración de Docker a Podman — El caso real de atareao.es
No hay mejor forma de aprender que con un caso real. A mediados de enero de 2026, atareao.es, un sitio con décadas de historia, múltiples servicios y una infraestructura que había crecido orgánicamente, inició la migración completa de Docker a Podman.
El escenario original
Antes de la migración, la infraestructura de atareao.es se basaba en:
- Docker Engine con
docker-compose.ymlpara orquestar servicios. - Traefik como proxy reverso, configurado con etiquetas Docker.
- Contenedores ejecutándose como root a través del daemon de Docker.
- Watchtower para actualizaciones automáticas.
- Scripts caseros para backup y monitoreo.
- Una mezcla de configuraciones repartidas entre
/etc/docker/,/opt/y el home del usuario.
Los problemas eran evidentes: un daemon monolítico con permisos de root, actualizaciones que a veces rompían cosas sin rollback automático, y una gestión de configuraciones que dependía de la memoria del administrador.
La decisión: migrar a Podman
La decisión no fue trivial. Migrar una infraestructura que funciona siempre da miedo. Pero las razones eran contundentes:
- Seguridad rootless: eliminar el daemon con permisos de root.
- Quadlets: servicios gestionados con systemd, no con scripts caseros.
- Auto-Update con rollback: más seguro que Watchtower.
- Sin dependencia de un daemon: si algo falla, no se pierde el control.
- Integración nativa con systemd: logs, dependencias, auto-arranque.
El plan de migración en 7 pasos
Paso 1: Inventario de servicios
# Listar todos los contenedores Docker
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
# Listar volúmenes
docker volume ls
# Listar redes
docker network ls
# Extraer configuraciones de docker-compose.yml
cat docker-compose.yml
El inventario reveló estos servicios:
| Servicio | Imagen | Puerto | Volumen |
|---|---|---|---|
| traefik | traefik:v3.0 | 80,443 | traefik-config |
| wordpress | wordpress:6.7 | 8080 | wp-content |
| postgres | postgres:16-alpine | 5432 | postgres-data |
| redis | redis:7-alpine | 6379 | redis-data |
| minio | minio/minio:latest | 9000 | minio-data |
Paso 2: Preparar el entorno rootless
# Verificar rangos subuid/subgid
grep $USER /etc/subuid
grep $USER /etc/subgid
# Instalar paquetes necesarios
sudo apt install podman slirp4netns fuse-overlayfs
# Crear directorios para Quadlets
mkdir -p ~/.config/containers/systemd/
# Activar linger
sudo loginctl enable-linger $USER
Paso 3: Convertir docker-compose.yml a Quadlets
Cada servicio del docker-compose.yml se convirtió en un Quadlet .container. Por ejemplo, Traefik:
Antes (docker-compose.yml):
version: '3.8'
services:
traefik:
image: traefik:v3.0
container_name: traefik
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik-config:/etc/traefik:Z
labels:
- "traefik.enable=true"
restart: always
Después (Quadlet traefik.container):
[Unit]
Description=Traefik reverse proxy
Documentation=https://atareao.es/tutorial/podman/
After=network-online.target
Wants=network-online.target
Requires=traefik-config.volume
After=traefik-config.volume
BindsTo=traefik.network
After=traefik.network
[Container]
Image=docker.io/library/traefik:v3.0
ContainerName=traefik
PublishPort=80:80
PublishPort=443:443
PublishPort=8080:8080
Volume=traefik-config.volume:/etc/traefik:Z
Network=traefik.network
NetworkAlias=traefik
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=32M
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
AddCapability=CHOWN
AddCapability=DAC_OVERRIDE
SecurityLabel=no-new-privileges
AutoUpdate=registry
HealthCmd=wget -qO- http://localhost:8080/ping || exit 1
HealthInterval=30s
LogDriver=journald
[Service]
Restart=always
RestartSec=10s
TimeoutStartSec=300
[Install]
WantedBy=default.target
Paso 4: Migrar los volúmenes de datos
Los datos existentes en volúmenes Docker no desaparecen al migrar. Hay que copiarlos:
# Crear los volúmenes Podman
podman volume create traefik-config
podman volume create postgres-data
podman volume create wp-content
podman volume create redis-data
podman volume create minio-data
# Copiar datos de los volúmenes Docker a Podman
# Opción 1: usando docker cp y podman cp (recomendada)
mkdir -p /tmp/migrate-temp
docker run -d --name temp-migrate \
-v traefik-config:/docker-data:ro \
alpine sleep 3600
docker cp temp-migrate:/docker-data/. /tmp/migrate-temp/
docker stop temp-migrate && docker rm temp-migrate
podman run -d --name temp-podman \
-v traefik-config:/podman-data \
alpine sleep 3600
podman cp /tmp/migrate-temp/. temp-podman:/podman-data/
podman stop temp-podman && podman rm temp-podman
rm -rf /tmp/migrate-temp
# Opción 2 (más elegante): montar ambos volúmenes en un mismo contenedor
# docker run --rm -v traefik-config:/from:ro -v traefik-config:/to alpine sh -c "cp -a /from/* /to/"
Paso 5: Probar la migración en paralelo
Lo más inteligente fue mantener Docker funcionando mientras se probaba Podman:
# Detener solo un servicio en Docker
docker stop postgres
# Arrancar la versión Podman
systemctl --user start postgres
# Verificar logs
journalctl --user -u postgres -f
# Probar conectividad
podman exec postgres pg_isready -U wordpress
Paso 6: Migrar Traefik y el DNS
Traefik es el punto de entrada. Migrarlo al final, cuando todos los servicios de backend ya estén funcionando en Podman:
# Detener Traefik en Docker
docker stop traefik
# Arrancar Traefik en Podman
systemctl --user start traefik
# Verificar que los certificados SSL se cargan
journalctl --user -u traefik -n 50
# Probar acceso web
curl -I https://atareao.es
Paso 7: Limpiar y celebrar
Una vez verificado que todo funciona:
# Detener Docker por completo
sudo systemctl stop docker
sudo systemctl disable docker
# Opcional: eliminar Docker (cuando estés seguro)
sudo apt remove docker-ce docker-ce-cli containerd.io
# Activar auto-update en Podman
systemctl --user enable --now podman-auto-update.timer
Lecciones aprendidas
La migración de atareao.es dejó varias lecciones valiosas:
- Haz la migración servicio por servicio, no todo a la vez. Mantén Docker corriendo hasta que confíes en Podman.
- Los volúmenes Docker son perfectamente compatibles. Solo hay que copiar los datos.
- Traefik necesita
DAC_OVERRIDEpara leer sus propios archivos de configuración. Es una capacidad que normalmente intentamos evitar, pero a veces es necesaria. - Los logs con journald son muy superiores a
docker logs. - El rollback automático de auto-update ya salvó el servicio en dos ocasiones durante los primeros meses.
- YADM es indispensable para gestionar las configuraciones entre el servidor de pruebas y el de producción.
- Y como de costumbre, el problema había sido mucho más sencillo, y de nuevo, un error humano. La primera vez que arrancamos Traefik en Podman y no respondía, estuvimos horas mirando logs, configuraciones, permisos… hasta que alguien se dio cuenta de que habíamos olvidado el
:Zen el volumen. Cosas que pasan.
El resultado: una infraestructura más segura (rootless), más robusta (Quadlets + systemd), más autónoma (auto-update con rollback) y con mejor visibilidad (journald + healthchecks).
Una vez migrado, el siguiente paso es asegurar los datos. Porque los contenedores son efímeros, pero los datos no.
Backup y restauración
Los contenedores son efímeros. Los datos, no. Una estrategia de backup sólida es tan importante como el hardening.
Qué hay que respaldar
En un entorno Podman, hay cuatro cosas que respaldar:
- Volúmenes de datos: los persistentes (PostgreSQL, MinIO, WordPress).
- Configuraciones: los Quadlets, perfiles seccomp, scripts, dotfiles (YADM se encarga de esto).
- Imágenes: aunque se pueden volver a descargar, tener un backup local acelera la recuperación.
- Metadata: nombres de contenedores, redes, etiquetas.
Script de backup completo
#!/bin/bash
# ~/.local/bin/backup-podman.sh
# Backup completo del entorno Podman
set -euo pipefail
BACKUP_DIR="${HOME}/backups/podman/$(date +%Y%m%d_%H%M%S)"
RETENTION_DAYS=30
LOG_FILE="${HOME}/logs/podman-backup.log"
mkdir -p "${BACKUP_DIR}"
mkdir -p "$(dirname "${LOG_FILE}")"
echo "[$(date)] Iniciando backup de Podman..." | tee -a "${LOG_FILE}"
# 1. Listar los volúmenes y guardar metadatos
echo "→ Guardando metadatos de volúmenes..." | tee -a "${LOG_FILE}"
podman volume ls --format "{{.Name}}" > "${BACKUP_DIR}/volumes-list.txt"
# 2. Backup de cada volumen
echo "→ Respaldo de volúmenes..." | tee -a "${LOG_FILE}"
while read -r volume; do
echo " - Respaldando volumen: ${volume}" | tee -a "${LOG_FILE}"
podman volume export "${volume}" > "${BACKUP_DIR}/${volume}.tar"
done < "${BACKUP_DIR}/volumes-list.txt"
# 3. Listar las imágenes (sin las efímeras)
echo "→ Guardando lista de imágenes..." | tee -a "${LOG_FILE}"
podman images --format "{{.Repository}}:{{.Tag}} ({{.ID}})" > "${BACKUP_DIR}/images-list.txt"
# 4. Exportar imágenes clave (opcional, puede ser pesado)
echo "→ Exportando imágenes del registry local..." | tee -a "${LOG_FILE}"
podman images --format "{{.Repository}}:{{.Tag}}" | grep -v "<none>" | while read -r img; do
safe_name=$(echo "${img}" | tr '/' '_' | tr ':' '_')
echo " - Exportando imagen: ${img}" | tee -a "${LOG_FILE}"
podman save -o "${BACKUP_DIR}/images/${safe_name}.tar" "${img}" 2>/dev/null || true
done
# 5. Backup de la configuración de Quadlets (YADM ya lo hace, pero redundancia)
echo "→ Respaldando configuraciones de Podman..." | tee -a "${LOG_FILE}"
tar czf "${BACKUP_DIR}/containers-config.tar.gz" -C "${HOME}" .config/containers/ .config/systemd/user/
# 6. Listar los contenedores en ejecución
echo "→ Guardando estado de contenedores..." | tee -a "${LOG_FILE}"
podman ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" > "${BACKUP_DIR}/containers-running.txt"
podman ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" > "${BACKUP_DIR}/containers-all.txt"
# 7. Backup de las redes
echo "→ Guardando configuración de redes..." | tee -a "${LOG_FILE}"
podman network ls --format "{{.Name}}" > "${BACKUP_DIR}/networks-list.txt"
# 8. Resumen
echo "" | tee -a "${LOG_FILE}"
echo "=== RESUMEN DEL BACKUP ===" | tee -a "${LOG_FILE}"
echo "Directorio: ${BACKUP_DIR}" | tee -a "${LOG_FILE}"
echo "Volúmenes: $(wc -l < "${BACKUP_DIR}/volumes-list.txt")" | tee -a "${LOG_FILE}"
echo "Imágenes: $(wc -l < "${BACKUP_DIR}/images-list.txt")" | tee -a "${LOG_FILE}"
echo "Tamaño total: $(du -sh "${BACKUP_DIR}" | cut -f1)" | tee -a "${LOG_FILE}"
# 9. Limpiar backups antiguos
echo "→ Limpiando backups anteriores a ${RETENTION_DAYS} días..." | tee -a "${LOG_FILE}"
find "${HOME}/backups/podman/" -maxdepth 1 -type d -mtime +"${RETENTION_DAYS}" -exec rm -rf {} \;
echo "[$(date)] Backup completado correctamente." | tee -a "${LOG_FILE}"
Script de restauración
#!/bin/bash
# ~/.local/bin/restore-podman.sh
# Restaura un backup de Podman
set -euo pipefail
BACKUP_DIR="${1:-}"
LOG_FILE="${HOME}/logs/podman-restore.log"
if [ -z "${BACKUP_DIR}" ]; then
echo "Uso: $0 /ruta/al/backup/"
echo "Backups disponibles:"
ls -d "${HOME}/backups/podman/"*/
exit 1
fi
if [ ! -d "${BACKUP_DIR}" ]; then
echo "Error: ${BACKUP_DIR} no existe."
exit 1
fi
mkdir -p "$(dirname "${LOG_FILE}")"
echo "[$(date)] Iniciando restauración desde ${BACKUP_DIR}..." | tee -a "${LOG_FILE}"
# 1. Restaurar configuraciones de Quadlets
if [ -f "${BACKUP_DIR}/containers-config.tar.gz" ]; then
echo "→ Restaurando configuraciones de Podman..." | tee -a "${LOG_FILE}"
tar xzf "${BACKUP_DIR}/containers-config.tar.gz" -C "${HOME}"
fi
# 2. Restaurar volúmenes
echo "→ Restaurando volúmenes..." | tee -a "${LOG_FILE}"
while read -r volume; do
vol_file="${BACKUP_DIR}/${volume}.tar"
if [ -f "${vol_file}" ]; then
# Crear volumen si no existe
podman volume inspect "${volume}" &>/dev/null || podman volume create "${volume}"
echo " - Restaurando volumen: ${volume}" | tee -a "${LOG_FILE}"
podman volume import "${volume}" "${vol_file}"
else
echo " - ⚠ Volumen ${volume} no encontrado en el backup" | tee -a "${LOG_FILE}"
fi
done < "${BACKUP_DIR}/volumes-list.txt"
# 3. Restaurar imágenes (si están en el backup)
if [ -d "${BACKUP_DIR}/images" ]; then
echo "→ Restaurando imágenes locales..." | tee -a "${LOG_FILE}"
for img_file in "${BACKUP_DIR}/images/"*.tar; do
if [ -f "${img_file}" ]; then
echo " - Cargando imagen: $(basename "${img_file}")" | tee -a "${LOG_FILE}"
podman load -i "${img_file}"
fi
done
fi
# 4. Recargar Quadlets y arrancar servicios
echo "→ Recargando Quadlets..." | tee -a "${LOG_FILE}"
systemctl --user daemon-reload
echo "→ Arrancando servicios..." | tee -a "${LOG_FILE}"
# Arrancar los que estaban en ejecución
while read -r line; do
service_name=$(echo "${line}" | awk '{print $1}')
if systemctl --user list-units --type=service --all | grep -q "${service_name}"; then
echo " - Arrancando: ${service_name}" | tee -a "${LOG_FILE}"
systemctl --user start "${service_name}"
fi
done < <(tail -n +2 "${BACKUP_DIR}/containers-running.txt" 2>/dev/null || true)
echo "[$(date)] Restauración completada." | tee -a "${LOG_FILE}"
Automatización con systemd
Crea un servicio systemd para ejecutar el backup diariamente:
~/.config/systemd/user/podman-backup.service
[Unit]
Description=Backup diario de Podman
[Service]
Type=oneshot
ExecStart=%h/.local/bin/backup-podman.sh
~/.config/systemd/user/podman-backup.timer
[Unit]
Description=Ejecutar backup de Podman cada día a las 3 AM
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=default.target
Actívalo:
systemctl --user daemon-reload
systemctl --user enable --now podman-backup.timer
systemctl --user list-timers | grep podman-backup
Los backups están bien, pero ¿cómo saber que todo funciona sin mirar constantemente? Ahí entra el monitoring con alertas.
Monitoring y alertas
Tus contenedores están endurecidos, desplegados con Quadlets, respaldados diariamente. Pero ¿cómo sabes que están bien sin mirarlos constantemente?
Healthchecks nativos de Podman
Ya viste healthchecks en el capítulo 10. En producción, combínalos con Quadlets:
[Container]
HealthCmd=curl -f http://localhost:8443/health || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=60s
HealthTimeout=10s
Podman reporta el estado de salud en podman ps:
$ podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
abc123 mi-app:stable 2 days ago Up 2 days (healthy) mi-app
Script de monitorización con alertas
#!/bin/bash
# ~/.local/bin/monitor-podman.sh
# Monitorización del estado de Podman con alertas
set -euo pipefail
# Configuración
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
HEALTH_LOG="${HOME}/logs/podman-health.log"
mkdir -p "$(dirname "${HEALTH_LOG}")"
send_alert() {
local message="$1"
echo "[$(date)] ALERTA: ${message}" | tee -a "${HEALTH_LOG}"
if [ -n "${TELEGRAM_BOT_TOKEN}" ] && [ -n "${TELEGRAM_CHAT_ID}" ]; then
curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d parse_mode="Markdown" \
-d text="🚨 *Podman Alert*%0A%0A${message}" > /dev/null
fi
}
# 1. Verificar que los servicios systemd están activos
echo "→ Verificando servicios systemd..." | tee -a "${HEALTH_LOG}"
systemctl --user list-units --type=service --state=running --no-legend | grep -i container | while read -r line; do
service=$(echo "${line}" | awk '{print $1}')
if ! systemctl --user is-active --quiet "${service}"; then
send_alert "❌ Servicio ${service} no está activo"
fi
done
# 2. Verificar healthchecks de Podman
echo "→ Verificando healthchecks..." | tee -a "${HEALTH_LOG}"
podman ps --format "{{.Names}} {{.Status}}" | while read -r name status; do
if echo "${status}" | grep -qi "unhealthy"; then
send_alert "⚠️ Contenedor ${name} está UNHEALTHY"
elif echo "${status}" | grep -qi "starting"; then
send_alert "ℹ️ Contenedor ${name} sigue en estado starting"
fi
done
# 3. Verificar espacio en disco de volúmenes
echo "→ Verificando espacio en volúmenes..." | tee -a "${HEALTH_LOG}"
podman volume ls --format "{{.Name}}" | while read -r vol; do
mountpoint=$(podman volume inspect "${vol}" --format "{{.Mountpoint}}")
if [ -d "${mountpoint}" ]; then
usage=$(df -h "${mountpoint}" | tail -1 | awk '{print $5}' | tr -d '%')
if [ "${usage}" -gt 85 ]; then
send_alert "💾 Volumen ${vol} al ${usage}% de capacidad"
fi
fi
done
# 4. Verificar que los Quadlets están sincronizados con systemd
echo "→ Verificando Quadlets..." | tee -a "${HEALTH_LOG}"
for quadlet in ~/.config/containers/systemd/*.container; do
if [ -f "${quadlet}" ]; then
service_name=$(basename "${quadlet}" .container)
if ! systemctl --user list-units --all --no-legend | grep -q "${service_name}"; then
send_alert "📄 Quadlet ${quadlet} no tiene servicio systemd asociado (olvidaste daemon-reload?)"
fi
fi
done
# 5. Resumen
unhealthy_count=$(podman ps --filter "health=unhealthy" --format "{{.Names}}" | wc -l)
running_count=$(podman ps --filter "status=running" --format "{{.Names}}" | wc -l)
echo "[$(date)] Monitorización completada: ${running_count} contenedores corriendo, ${unhealthy_count} unhealthy" | tee -a "${HEALTH_LOG}"
Y su timer systemd:
~/.config/systemd/user/podman-monitor.service
[Unit]
Description=Monitorización de Podman
[Service]
Type=oneshot
ExecStart=%h/.local/bin/monitor-podman.sh
Environment=TELEGRAM_BOT_TOKEN=tu_token
Environment=TELEGRAM_CHAT_ID=tu_chat_id
~/.config/systemd/user/podman-monitor.timer
[Unit]
Description=Monitorizar Podman cada 10 minutos
[Timer]
OnCalendar=*:0/10
Persistent=true
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now podman-monitor.timer
systemctl --user list-timers | grep podman-monitor
Integración con herramientas externas
Si tu infraestructura es más grande, considera:
- Prometheus + node_exporter: expone métricas del sistema. Complementa con
podman statsexportado via script. - Netdata: monitorización visual en tiempo real, con dashboard web.
- Uptime Kuma: healthchecks externos desde otro servidor.
- Healthchecks.io: servicio gratuito que recibe pings periódicos y alerta si no llegan.
Ejemplo de healthcheck externo con Healthchecks.io:
#!/bin/bash
# Enviar ping a Healthchecks.io después de cada backup
curl -fsS -m 10 --retry 5 \
"https://hc-ping.com/tu-uuid-aqui" \
-d "Backup completado: $(date)"
Y para cerrar el círculo, las actualizaciones automáticas seguras. Porque un sistema endurecido pero desactualizado también es vulnerable.
Actualizaciones automáticas seguras
Combinando lo que viste en el capítulo 11 con el hardening de este capítulo, aquí tienes la configuración definitiva para actualizaciones automáticas seguras.
Configuración de auto-update con notificaciones
# Crear el script de notificación
cat > ~/.local/bin/auto-update-seguro.sh << 'EOF'
#!/bin/bash
set -euo pipefail
TOKEN="${TELEGRAM_BOT_TOKEN}"
CHAT_ID="${TELEGRAM_CHAT_ID}"
LOG_FILE="${HOME}/logs/podman-auto-update.log"
mkdir -p "$(dirname "${LOG_FILE}")"
echo "[$(date)] Iniciando auto-update..." | tee -a "${LOG_FILE}"
# Ejecutar auto-update con dry-run primero
DRY_OUTPUT=$(podman auto-update --dry-run 2>&1)
echo "Dry-run output:" | tee -a "${LOG_FILE}"
echo "${DRY_OUTPUT}" | tee -a "${LOG_FILE}"
# Si hay actualizaciones pendientes
if echo "${DRY_OUTPUT}" | grep -qi "would update"; then
# Ejecutar la actualización real
OUTPUT=$(podman auto-update 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
MESSAGE="❌ *Auto-Update Falló*%0A%0A\`\`\`%0A${OUTPUT}%0A\`\`\`"
elif echo "${OUTPUT}" | grep -qi "rolled back\|rollback"; then
MESSAGE="⚠️ *Rollback ejecutado*%0A%0A\`\`\`%0A${OUTPUT}%0A\`\`\`"
elif echo "${OUTPUT}" | grep -qi "updated"; then
MESSAGE="✅ *Contenedores actualizados*%0A%0A\`\`\`%0A${OUTPUT}%0A\`\`\`"
else
MESSAGE="ℹ️ *Auto-Update: sin cambios*%0A%0A\`\`\`%0A${OUTPUT}%0A\`\`\`"
fi
else
MESSAGE="ℹ️ *Auto-Update: no hay actualizaciones disponibles*"
fi
# Enviar notificación
if [ -n "${TOKEN}" ] && [ -n "${CHAT_ID}" ]; then
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
-d chat_id="${CHAT_ID}" \
-d parse_mode="Markdown" \
-d text="${MESSAGE}" > /dev/null
fi
echo "[$(date)] Auto-update completado." | tee -a "${LOG_FILE}"
EOF
chmod +x ~/.local/bin/auto-update-seguro.sh
Luego sobreescribe el servicio de auto-update para que use tu script:
systemctl --user edit podman-auto-update.service
Añade:
[Service]
ExecStart=
ExecStart=%h/.local/bin/auto-update-seguro.sh
Environment=TELEGRAM_BOT_TOKEN=tu_token
Environment=TELEGRAM_CHAT_ID=tu_chat_id
Políticas de actualización seguras
Define políticas claras en containers-policy.json:
{
"default": [
{
"type": "reject"
}
],
"transports": {
"docker": {
"docker.io/miusuario/*": [
{
"type": "signedBy",
"keyType": "GPGKeys",
"keyPath": "/home/miusuario/.gnupg/mi-clave-pub.gpg"
}
],
"docker.io/library/*": [
{
"type": "insecureAcceptAnything"
}
]
}
}
}
Esta política asegura que solo las imágenes firmadas por ti desde tu registry de confianza sean aceptadas. Las imágenes de bibliotecas públicas (como nginx:alpine) se aceptan sin verificación (no son críticas), pero cualquier imagen fuera de esos dos orígenes es rechazada.
Infraestructura Podman hardened
Para cerrar este capítulo, aquí tienes el ejemplo completo: una infraestructura Podman completa, endurecida, respaldada, monitorizada y auto-actualizable. Todo lo que has aprendido en este capítulo, unido en un solo despliegue.
Estructura del proyecto
~/
├── .config/
│ └── containers/
│ └── systemd/
│ ├── cloe-traefik.container
│ ├── cloe-traefik-config.volume
│ ├── cloe-api.container
│ ├── cloe-db.container
│ ├── cloe-db-data.volume
│ ├── cloe-redis.container
│ ├── cloe-redis-data.volume
│ └── cloe.network
├── .local/
│ └── bin/
│ ├── backup-podman.sh
│ ├── restore-podman.sh
│ ├── monitor-podman.sh
│ └── auto-update-seguro.sh
├── seccomp/
│ └── perfil-api.json
├── app/
│ ├── Dockerfile
│ ├── app.py
│ └── requirements.txt
└── .env
Paso 1: Red y volúmenes
~/.config/containers/systemd/cloe.network
[Network]
Subnet=10.91.0.0/24
~/.config/containers/systemd/cloe-traefik-config.volume
[Volume]
~/.config/containers/systemd/cloe-db-data.volume
[Volume]
~/.config/containers/systemd/cloe-redis-data.volume
[Volume]
Paso 2: Base de datos (PostgreSQL hardened)
~/.config/containers/systemd/cloe-db.container
[Unit]
Description=PostgreSQL hardened
Requires=cloe-db-data.volume
After=cloe-db-data.volume
BindsTo=cloe.network
After=cloe.network
[Container]
Image=docker.io/library/postgres:16-alpine
ContainerName=cloe-db
Environment=POSTGRES_DB=cloe
Environment=POSTGRES_USER=cloe
Environment=POSTGRES_PASSWORD=Cl03S3cr3t!
Volume=cloe-db-data.volume:/var/lib/postgresql/data:Z
Network=cloe.network
NetworkAlias=db
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=64M
DropCapability=ALL
AddCapability=CHOWN
AddCapability=DAC_OVERRIDE
AddCapability=SETUID
AddCapability=SETGID
SecurityLabel=no-new-privileges
HealthCmd=pg_isready -U cloe
HealthInterval=30s
HealthRetries=5
HealthStartPeriod=90s
AutoUpdate=registry
LogDriver=journald
[Service]
Restart=always
RestartSec=10s
TimeoutStartSec=300
[Install]
WantedBy=default.target
Paso 3: Redis (caché hardened)
~/.config/containers/systemd/cloe-redis.container
[Unit]
Description=Redis cache hardened
Requires=cloe-redis-data.volume
After=cloe-redis-data.volume
BindsTo=cloe.network
After=cloe.network
[Container]
Image=docker.io/library/redis:7-alpine
ContainerName=cloe-redis
Volume=cloe-redis-data.volume:/data:Z
Network=cloe.network
NetworkAlias=redis
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=32M
DropCapability=ALL
AddCapability=CHOWN
AddCapability=SETGID
AddCapability=SETUID
SecurityLabel=no-new-privileges
HealthCmd=redis-cli ping
HealthInterval=30s
AutoUpdate=registry
LogDriver=journald
[Service]
Restart=always
RestartSec=10s
TimeoutStartSec=120
[Install]
WantedBy=default.target
Paso 4: API (máximo hardening)
~/.config/containers/systemd/cloe-api.container
[Unit]
Description=API de la aplicación Cloe (hardened)
Requires=cloe-db.container
After=cloe-db.container
Requires=cloe-redis.container
After=cloe-redis.container
BindsTo=cloe.network
After=cloe.network
BindsTo=cloe-traefik.container
After=cloe-traefik.container
[Container]
Image=localhost/cloe-api:latest
ContainerName=cloe-api
Environment=DB_HOST=db
Environment=DB_NAME=cloe
Environment=DB_USER=cloe
Environment=DB_PASSWORD=Cl03S3cr3t!
Environment=REDIS_HOST=redis
Network=cloe.network
NetworkAlias=api
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=64M
TmpFs=/var/run:noexec,nosuid,size=32M
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
SecurityLabel=no-new-privileges
SecurityLabel=seccomp=/home/miusuario/seccomp/perfil-api.json
Secret=db-password,type=env,target=DB_PASSWORD
Secret=api-key,type=file,target=/run/secrets/api-key
AutoUpdate=registry
HealthCmd=curl -f http://localhost:5000/health || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=60s
HealthTimeout=10s
LogDriver=journald
Label=app=cloe
Label=env=production
[Service]
Restart=always
RestartSec=10s
TimeoutStartSec=300
[Install]
WantedBy=default.target
Paso 5: Traefik (proxy reverso)
~/.config/containers/systemd/cloe-traefik.container
[Unit]
Description=Traefik reverse proxy
Requires=cloe-traefik-config.volume
After=cloe-traefik-config.volume
BindsTo=cloe.network
After=cloe.network
[Container]
Image=docker.io/library/traefik:v3.0
ContainerName=cloe-traefik
PublishPort=80:80
PublishPort=443:443
Volume=cloe-traefik-config.volume:/etc/traefik:Z
Network=cloe.network
NetworkAlias=traefik
ReadOnly=true
TmpFs=/tmp:noexec,nosuid,size=32M
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
AddCapability=CHOWN
AddCapability=DAC_OVERRIDE
SecurityLabel=no-new-privileges
AutoUpdate=registry
HealthCmd=wget -qO- http://localhost:8080/ping || exit 1
HealthInterval=30s
LogDriver=journald
[Service]
Restart=always
RestartSec=10s
TimeoutStartSec=300
[Install]
WantedBy=default.target
Paso 6: Activar todo
# Recargar systemd
systemctl --user daemon-reload
# Arrancar todo en orden
systemctl --user start cloe-traefik-config.volume
systemctl --user start cloe-db-data.volume
systemctl --user start cloe-redis-data.volume
systemctl --user start cloe.network
systemctl --user start cloe-db
systemctl --user start cloe-redis
systemctl --user start cloe-api
systemctl --user start cloe-traefik
# Habilitar auto-arranque
systemctl --user enable cloe-traefik cloe-api cloe-db cloe-redis
# Activar timer de auto-update
systemctl --user enable --now podman-auto-update.timer
# Activar backup diario
systemctl --user enable --now podman-backup.timer
# Activar monitorización
systemctl --user enable --now podman-monitor.timer
# Verificar estado
systemctl --user status 'cloe-*'
Paso 7: Verificar el estado de todo el sistema
# Ver todos los contenedores
podman ps
# Ver healthchecks
podman ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
# Ver logs centralizados
journalctl --user -u cloe-api -n 50 --no-pager
journalctl --user -u cloe-traefik -n 50 --no-pager
# Verificar auto-update
podman auto-update --dry-run
# Verificar timers activos
systemctl --user list-timers | grep -E "podman-(backup|monitor|auto-update)"
# Verificar backups recientes
ls -la ~/backups/podman/
Conclusiones
Vale, repasemos lo que has visto: has aprendido a llevar tus contenedores de Podman al siguiente nivel, el despliegue en producción con hardening en profundidad.
Sobre hardening:
- Read-only rootfs: el sistema de archivos del contenedor es inmutable, excepto los tmpfs que tú definas.
- Tmpfs: directorios temporales en RAM, con límites de tamaño y sin ejecución de binarios.
- No-new-privileges: impide cualquier escalada de privilegios mediante setuid, setgid o capacidades.
- Capabilities: aplica el principio de mínimo privilegio: dropea todas y añade solo las necesarias.
- Seccomp: lista blanca de syscalls. Solo las que tu aplicación necesita, nada más.
- AppArmor y SELinux: control de acceso obligatorio que añade una capa adicional de seguridad.
Sobre despliegue rootless completo:
- Linger para que los servicios sobrevivan al cierre de sesión.
- Quadlets rootless con hardening como servicios systemd de primera clase.
- Estrategias para puertos privilegiados y redes rootless.
Sobre YADM para dotfiles:
- Tus Quadlets, perfiles seccomp, scripts y configuraciones versionados con Git.
- Restauración completa en un servidor nuevo en menos de 5 minutos.
Sobre la migración de atareao.es:
- Un caso real de migración de Docker a Podman, paso a paso.
- 7 pasos: inventario, preparación, conversión a Quadlets, migración de datos, pruebas en paralelo, migración de Traefik, limpieza.
Sobre backup y restauración:
- Script completo que respalda volúmenes, imágenes, configuraciones y metadatos.
- Script de restauración que reconstruye todo el entorno desde un backup.
- Automatización con timers systemd.
Sobre monitoring y alertas:
- Healthchecks nativos de Podman integrados en Quadlets.
- Script de monitorización con alertas a Telegram.
- Timers systemd para monitorización periódica.
Sobre actualizaciones automáticas seguras:
- Script de auto-update con notificaciones y detección de rollbacks.
- Políticas de firma de imágenes con
containers-policy.json.
El ejemplo completo te ha mostrado cómo unir todo en un solo despliegue: una infraestructura Podman completa con PostgreSQL, Redis, API Python y Traefik, todos endurecidos, respaldados, monitorizados y auto-actualizables.
La seguridad en contenedores no es una característica que se activa con un flag. Es una mentalidad. Cada capa de hardening que añades reduce la probabilidad de que un incidente de seguridad se convierta en una brecha real. Y cuando todas las capas trabajan juntas, rootless + read-only + tmpfs + no-new-privileges + capabilities mínimas + seccomp + AppArmor/SELinux + Quadlets + backups + monitoring + auto-update, tu infraestructura se vuelve verdaderamente robusta.
Has llegado al final de este tutorial de Podman. Has pasado de no saber qué es un contenedor a desplegar infraestructuras completas, endurecidas y auto-gestionadas. Ha sido un viaje largo, lo sé. Pero te aseguro que cada hora invertida en entender estas capas de seguridad se amortiza la primera vez que evitas un problema gordo. Yo he dormido mucho más tranquilo desde que aplico estas prácticas, y espero que tú también.
Más información