Docker нормально работает даже на VPS с 1 GB, если установить его из официального репозитория Docker, ограничить логи контейнеров, задать каждому контейнеру лимит памяти, публиковать порты на 127.0.0.1 за обратным прокси (иначе Docker обходит UFW и firewalld) и регулярно удалять старые образы. В этой инструкции каждый шаг разобран для Debian 12/13, Ubuntu 22.04/24.04 и Rocky Linux / AlmaLinux.
Шаг 1: Установите Docker из официального репозитория
Пакет docker.io в Debian и Ubuntu часто устаревший и без плагина Compose. Используйте собственный репозиторий Docker. В Debian и Ubuntu выполните от 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
В Rocky Linux и AlmaLinux на некоторых образах уже установлен Podman, который конфликтует с пакетами Docker, поэтому сначала удалите его:
# 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
В Debian и Ubuntu служба запускается автоматически. Проверьте, что всё работает:
systemctl is-active docker
docker version
docker compose version
docker run --rm hello-world
docker управляют демоном, а это равносильно root-доступу к серверу. Добавляйте только доверенных пользователей командой usermod -aG docker username и никогда не открывайте API Docker (/var/run/docker.sock или TCP-порт 2375) в интернет.Шаг 2: Ограничьте логи контейнеров и включите live-restore
По умолчанию Docker хранит каждую строку, которую выводит контейнер, без ограничения размера. «Болтливый» контейнер способен за несколько дней заполнить диск на 20 GB. Задайте общий лимит в /etc/docker/daemon.json:
# /etc/docker/daemon.json
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
- Драйвер логов
local: сжатые файлы с ротацией; здесь не больше 3 × 10 MB на контейнер.docker logsработает как прежде. live-restore: контейнеры продолжают работать, пока демон Docker перезапускается или обновляется, так что обновление пакетов не роняет сайт.
Если какой-то инструмент на сервере читает JSON-логи напрямую, используйте json-file с теми же лимитами:
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
Проверьте файл и перезапустите Docker. При этом первом перезапуске работающие контейнеры один раз остановятся:
dockerd --validate --config-file /etc/docker/daemon.json
systemctl restart docker
docker info --format '{{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'
docker compose up -d --force-recreate.Шаг 3: Создайте compose-файл с лимитами памяти и CPU
Без лимитов один контейнер с утечкой памяти может загнать весь VPS в swap или заставить ядро убить sshd или базу данных. С лимитом будет убит и перезапущен только этот контейнер. Небольшое приложение с MariaDB на тарифе 1 GB может выглядеть так:
# /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: жёсткий лимит памяти. Одинаковые значения запрещают контейнеру использовать swap. Оставьте место системе: на 1 GB держите сумму всех лимитов в районе 600–700 MB.cpus:1.0— одно полное ядро. Так загруженный контейнер не выведет весь VPS за среднюю загрузку CPU по правилам fair use.restart: unless-stopped: контейнеры поднимаются после сбоя или перезагрузки, но не после того, как вы остановили их сами.healthcheck: помечает контейнер как healthy или unhealthy, аdepends_onждёт готовности базы. Команда проверки (здесьwget) должна быть внутри образа. Учтите: обычный Docker не перезапускает unhealthy-контейнер, а лишь показывает статус.
Храните секреты в файле .env рядом с compose.yaml и запустите стек:
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
Шаг 4: Не попадитесь на обход файрвола (UFW, firewalld и Docker)
Это самая частая ошибка безопасности с Docker. Когда вы публикуете порт через -p 8080:8080 или "8080:8080", Docker добавляет свои правила NAT и пересылки. Такой трафик не проходит через правила INPUT, которыми управляют UFW и firewalld, поэтому порт открыт всему интернету, даже если ufw status показывает, что он закрыт.
Решения в порядке предпочтения:
- Публикуйте на 127.0.0.1 (
"127.0.0.1:8080:8080", как в compose-файле выше). Порт доступен только с самого VPS. - Поставьте перед контейнером обратный прокси. Nginx или Caddy на хосте слушает 80/443 (их вы открываете в UFW или firewalld как обычно) и передаёт запросы на
127.0.0.1:8080. Заодно HTTPS настраивается в одном месте. - Вообще не публикуйте внутренние сервисы. Базам данных и кэшам достаточно сети Compose: контейнеры одного проекта находят друг друга по имени сервиса (
db:3306).
Минимальный сайт Nginx для приложения (HTTPS через Let's Encrypt добавляется, как описано в нашей инструкции по Nginx и 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;
}
}
Проверьте, что на самом деле открыто наружу:
# 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
Если порт действительно должен быть публичным, но только для определённых адресов, добавьте правила в цепочку DOCKER-USER — Docker проверяет её раньше своих правил. --ctorigdstport сопоставляет порт в том виде, в каком его отправил клиент, до трансляции адресов 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 не сохраняются автоматически и пропадают после перезагрузки, а ошибочное правило может отрезать контейнеры от сети. Привязка к 127.0.0.1 проще и переживает перезагрузку. Настройка самого файрвола хоста описана в инструкции по UFW и firewalld.Шаг 5: Следите за чистотой диска
Старые образы, остановленные контейнеры и кэш сборки быстро накапливаются на диске в 10–40 GB. Сначала посмотрите, сколько занимает Docker:
docker system df # images, containers, volumes, build cache
docker system df -v # per image / container / volume
df -h / && du -sh /var/lib/docker
Затем чистите поэтапно — от безопасного к рискованному:
# 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 и docker system prune --volumes удаляют тома, которые в этот момент не использует ни один контейнер. Если вы остановили и удалили контейнер с базой, его том считается неиспользуемым, и данные пропадут. Всегда сначала выполняйте docker volume ls и делайте бэкап нужного.Еженедельная очистка, которая никогда не трогает тома:
# /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
Если диск уже заполнен, см. как найти и освободить место на диске.
Шаг 6: Swap или zram на тарифах 512 MB и 1 GB
Немного swap защищает небольшой VPS от внезапных убийств по нехватке памяти, когда пики у нескольких контейнеров совпадают. Классический вариант — swap-файл на диске (см. как добавить swap). В Debian и Ubuntu хорошим дополнением будет zram: сжатый swap прямо в RAM, он гораздо быстрее и не создаёт нагрузки на диск:
# 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
В Rocky Linux и AlmaLinux используйте swap-файл. Swap — это страховка, а не дополнительная память: если free -h показывает, что swap занят постоянно, тариф мал для ваших контейнеров.
Шаг 7: Обновляйте контейнеры безопасно
Закрепляйте версии образов вместо :latest: тогда обновление происходит только по вашему решению, и вы всегда знаете, на какую версию откатиться:
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"
Сам Docker обновляйте вместе с системными пакетами. Благодаря live-restore контейнеры во время обновления продолжают работать:
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
Хватит ли тарифа с 512 MB для Docker?
Для одного-двух лёгких контейнеров (статический сайт, небольшой прокси или бот) — да, при наличии swap и строгих лимитов памяти. База данных плюс веб-приложение гораздо комфортнее работают на 1–2 GB.
Почему контейнер постоянно перезапускается?
Посмотрите docker compose logs app и docker inspect $(docker compose ps -q app) --format '{{.State.OOMKilled}}'. Если ответ true, контейнер упёрся в лимит памяти: увеличьте mem_limit, уменьшите потребление памяти приложением или повысьте тариф на странице услуги.
Может ли JUSTG помочь с контейнерами?
Проблемы самого VPS, например с сетью или доступом к консоли, решаются через тикет в Technical support. У JUSTG работает AI-поддержка 24/7 по тикетам, а инженеры подключаются, когда нужен человек.