Docker funciona bien en un VPS de 1 GB si lo instalas desde el repositorio oficial de Docker, limitas los logs de los contenedores, das a cada contenedor un límite de memoria, publicas los puertos en 127.0.0.1 detrás de un proxy inverso (si no, Docker se salta UFW y firewalld) y eliminas las imágenes antiguas con regularidad. Esta guía recorre cada paso en Debian 12/13, Ubuntu 22.04/24.04 y Rocky Linux / AlmaLinux.
Paso 1: Instala Docker desde el repositorio oficial
El paquete docker.io de Debian y Ubuntu suele ser más antiguo y no trae el plugin de Compose. Usa el repositorio propio de Docker. En Debian y Ubuntu, ejecuta como root:
# Debian 12/13 and Ubuntu 22.04/24.04 - remove distro packages that conflict
for p in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
apt-get remove -y "$p" 2>/dev/null
done
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
. /etc/os-release # sets ID (debian/ubuntu) and VERSION_CODENAME
curl -fsSL "https://download.docker.com/linux/$ID/gpg" -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/$ID
Suites: $VERSION_CODENAME
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
En Rocky Linux y AlmaLinux, algunas imágenes traen Podman preinstalado, que choca con los paquetes de Docker, así que elimínalo primero:
# Rocky Linux / AlmaLinux 8, 9, 10
dnf remove -y podman buildah runc 2>/dev/null
dnf install -y dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
En Debian y Ubuntu el servicio arranca solo. Comprueba que todo funciona:
systemctl is-active docker
docker version
docker compose version
docker run --rm hello-world
docker controlan el daemon, lo que equivale a ser root en el servidor. Añade solo usuarios de confianza con usermod -aG docker username y nunca expongas la API de Docker (/var/run/docker.sock o el puerto TCP 2375) a internet.Paso 2: Limita los logs de los contenedores y activa live-restore
Por defecto, Docker guarda cada línea que imprime un contenedor, sin límite de tamaño. Un contenedor muy hablador puede llenar un disco de 20 GB en pocos días. Fija un límite global en /etc/docker/daemon.json:
# /etc/docker/daemon.json
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
- Driver de logs
local: archivos comprimidos y rotados; aquí, como máximo 3 × 10 MB por contenedor.docker logssigue funcionando. live-restore: los contenedores siguen en marcha mientras el daemon de Docker se reinicia o se actualiza, así que una actualización de paquetes no tumba tu web.
Si alguna herramienta del servidor lee directamente los logs JSON, usa json-file con los mismos límites:
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
Valida el archivo y reinicia Docker. En este primer reinicio, los contenedores en marcha se detienen una vez:
dockerd --validate --config-file /etc/docker/daemon.json
systemctl restart docker
docker info --format '{{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'
docker compose up -d --force-recreate.Paso 3: Escribe un archivo compose con límites de memoria y CPU
Sin límites, un contenedor con una fuga de memoria puede llevar todo el VPS al swap o hacer que el kernel mate sshd o tu base de datos. Con un límite, solo se mata y reinicia ese contenedor. Una app pequeña con MariaDB en un plan de 1 GB podría quedar así:
# /opt/app/compose.yaml
services:
app:
image: ghcr.io/example/app:1.4.2 # pin a version, avoid :latest
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080" # reachable only from the VPS itself
mem_limit: 256m
memswap_limit: 256m # no extra swap for this container
cpus: 1.0
pids_limit: 200
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
db:
condition: service_healthy
db:
image: mariadb:11.4
restart: unless-stopped
command: ["--innodb-buffer-pool-size=64M"]
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: app
volumes:
- db_data:/var/lib/mysql
mem_limit: 300m
memswap_limit: 300m
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
volumes:
db_data:
mem_limit/memswap_limit: un tope de memoria estricto. Si pones el mismo valor en ambos, el contenedor no usa swap. Deja sitio al sistema: con 1 GB, mantén la suma de todos los límites en unos 600–700 MB.cpus:1.0es un núcleo completo. Así un contenedor ocupado no empuja todo el VPS por encima de la media de CPU del uso justo.restart: unless-stopped: los contenedores vuelven tras un fallo o un reinicio, pero no si los detienes tú a propósito.healthcheck: marca un contenedor como healthy o unhealthy, ydepends_onespera a la base de datos. El comando de prueba (aquíwget) tiene que existir dentro de la imagen. Ojo: Docker por sí solo no reinicia un contenedor unhealthy; solo informa del estado.
Guarda los secretos en un archivo .env junto a compose.yaml y arranca el stack:
cd /opt/app
echo 'DB_ROOT_PASSWORD=REPLACE_WITH_A_LONG_RANDOM_PASSWORD' > .env
chmod 600 .env
docker compose up -d
docker compose ps # STATUS should become "healthy"
docker stats --no-stream # memory use against each limit
Paso 4: Evita que Docker se salte el firewall (UFW, firewalld y Docker)
Es el error de seguridad más común con Docker. Cuando publicas un puerto con -p 8080:8080 o "8080:8080", Docker añade sus propias reglas de NAT y reenvío. Ese tráfico nunca pasa por las reglas INPUT que gestionan UFW y firewalld, así que el puerto queda abierto a todo internet aunque ufw status diga que está bloqueado.
Estas son las soluciones, por orden de preferencia:
- Publica en 127.0.0.1 (
"127.0.0.1:8080:8080", como en el archivo compose de arriba). El puerto solo es accesible desde el propio VPS. - Pon un proxy inverso delante. Nginx o Caddy en el host escucha en 80/443, que abres en UFW o firewalld como siempre, y reenvía a
127.0.0.1:8080. Además gestionas HTTPS en un solo sitio. - No publiques los servicios internos. Las bases de datos y cachés solo necesitan la red de Compose; los contenedores del mismo proyecto se encuentran por el nombre del servicio (
db:3306).
Un sitio mínimo de Nginx para la app (añade HTTPS con Let's Encrypt como se explica en nuestra guía de Nginx y Certbot):
# /etc/nginx/sites-available/app.example.com (Rocky/Alma: /etc/nginx/conf.d/app.conf)
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Comprueba qué está expuesto de verdad:
# On the VPS: what is published, and on which address?
docker ps --format '{{.Names}}\t{{.Ports}}'
ss -tlnp | grep docker-proxy
# From ANOTHER machine: this must fail for a port bound to 127.0.0.1
nc -vz 203.0.113.10 8080
Si un puerto tiene que ser público, pero solo para ciertas direcciones, añade reglas a la cadena DOCKER-USER, que Docker evalúa antes que sus propias reglas. --ctorigdstport compara el puerto tal como lo envió el cliente, antes de la traducción de direcciones de Docker:
# Allow new connections to published port 8080 only from 203.0.113.0/24
iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp \
-m conntrack --ctstate NEW --ctorigdstport 8080 -j DROP
iptables -L DOCKER-USER -n -v --line-numbers
DOCKER-USER no se guardan automáticamente y se pierden al reiniciar, y una regla equivocada puede dejar a los contenedores sin red. Vincular el puerto a 127.0.0.1 es más sencillo y sobrevive a los reinicios. Para el firewall del propio host, consulta nuestra guía de UFW y firewalld.Paso 5: Mantén el disco limpio
Las imágenes antiguas, los contenedores detenidos y la caché de compilación se acumulan rápido en un disco de 10–40 GB. Mira primero cuánto ocupa Docker:
docker system df # images, containers, volumes, build cache
docker system df -v # per image / container / volume
df -h / && du -sh /var/lib/docker
Después limpia paso a paso, de lo seguro a lo delicado:
# Safe: stopped containers, unused networks, dangling images, build cache
docker system prune
# Unused images (not used by any container) older than 7 days
docker image prune -a --filter "until=168h"
# Build cache only
docker builder prune
# CAREFUL: deletes volumes not used by any container - this is data
docker volume ls
docker volume prune
docker volume prune y docker system prune --volumes borran los volúmenes que ningún contenedor usa en ese momento. Si detuviste y eliminaste el contenedor de una base de datos, su volumen cuenta como no usado y los datos se pierden. Ejecuta siempre docker volume ls antes y haz copia de lo que necesites.Una limpieza semanal que nunca toca los volúmenes:
# /etc/cron.d/docker-prune - weekly, never touches volumes
0 4 * * 0 root docker image prune -af --filter "until=168h" >/dev/null && docker builder prune -f --filter "until=168h" >/dev/null
Si el disco ya está lleno, consulta cómo encontrar y liberar espacio en disco.
Paso 6: Usa swap o zram en los planes de 512 MB y 1 GB
Un poco de swap protege a un VPS pequeño de cierres repentinos por falta de memoria cuando varios contenedores tienen picos a la vez. La opción clásica es un archivo de swap en disco (consulta cómo añadir swap). En Debian y Ubuntu, zram es un buen complemento: guarda swap comprimido en la RAM, mucho más rápido y sin E/S de disco:
# Debian / Ubuntu: compressed swap in RAM
apt install -y zram-tools
cat > /etc/default/zramswap <<'EOF'
ALGO=zstd
PERCENT=50
PRIORITY=100
EOF
systemctl restart zramswap
swapon --show
# Prefer RAM, swap only under pressure
echo 'vm.swappiness=10' > /etc/sysctl.d/90-swappiness.conf
sysctl --system
En Rocky Linux y AlmaLinux, usa un archivo de swap. El swap es una red de seguridad, no RAM extra: si free -h muestra el swap en uso constante, el plan se queda pequeño para tus contenedores.
Paso 7: Actualiza los contenedores con seguridad
Fija versiones de imagen en lugar de :latest, así solo actualizas cuando tú decides y siempre sabes a qué versión volver:
cd /opt/app
# 1. Back up first (database dump; replace "app" with your database)
docker compose exec -T db sh -c 'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction app' > /root/app-$(date +%F).sql
# 2. Change the image tag in compose.yaml (e.g. 1.4.2 -> 1.5.0), then:
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=50 app
# 3. Roll back if needed: put the old tag back and run "docker compose up -d"
Actualiza el motor de Docker junto con los paquetes del sistema. Gracias a live-restore, los contenedores siguen funcionando durante la actualización:
apt-get update && apt-get install --only-upgrade -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Rocky / AlmaLinux
dnf upgrade -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
FAQ
¿Basta un plan de 512 MB para Docker?
Para uno o dos contenedores ligeros (una web estática, un proxy o un bot pequeños), sí, con swap y límites de memoria estrictos. Una base de datos más una app web van mucho más holgadas con 1–2 GB.
¿Por qué mi contenedor se reinicia una y otra vez?
Revisa docker compose logs app y docker inspect $(docker compose ps -q app) --format '{{.State.OOMKilled}}'. Si devuelve true, el contenedor llegó a su límite de memoria: sube mem_limit, reduce el consumo de la app o mejora el plan desde la página del servicio.
¿Puede JUSTG ayudarme con mis contenedores?
Los problemas del propio VPS, como la red o el acceso a la consola, se gestionan con un ticket a Technical support. JUSTG ofrece soporte con IA 24/7 por ticket, y los ingenieros intervienen cuando hace falta una persona.