Docker runs well on a 1 GB VPS if you install it from Docker's official repository, cap container logs, give every container a memory limit, publish ports on 127.0.0.1 behind a reverse proxy (Docker bypasses UFW and firewalld otherwise), and prune old images regularly. This guide walks through each step on Debian 12/13, Ubuntu 22.04/24.04 and Rocky Linux / AlmaLinux.
Step 1: Install Docker from the Official Repository
The docker.io package in Debian and Ubuntu is often older and lacks the Compose plugin. Use Docker's own repository instead. On Debian and Ubuntu, run as 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
On Rocky Linux and AlmaLinux, Podman is preinstalled on some images and conflicts with Docker's packages, so remove it first:
# 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
On Debian and Ubuntu the service starts automatically. Check that everything works:
systemctl is-active docker
docker version
docker compose version
docker run --rm hello-world
docker group can control the daemon, which is the same as having root on the server. Only add trusted users with usermod -aG docker username, and never expose the Docker API socket (/var/run/docker.sock or TCP port 2375) to the internet.Step 2: Limit Container Logs and Enable live-restore
By default Docker keeps every line a container prints, without a size limit. A chatty container can fill a 20 GB disk in days. Set a global limit in /etc/docker/daemon.json:
# /etc/docker/daemon.json
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
locallog driver: compressed, rotated files; at most 3 × 10 MB per container here.docker logsstill works.live-restore: containers keep running while the Docker daemon restarts or is upgraded, so a package update does not take your site down.
If a tool on the server reads the JSON log files directly, use json-file with the same limits instead:
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
Validate the file and restart Docker. Running containers stop once during this first restart:
dockerd --validate --config-file /etc/docker/daemon.json
systemctl restart docker
docker info --format '{{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'
docker compose up -d --force-recreate.Step 3: Write a Compose File with Memory and CPU Limits
Without limits, one container with a memory leak can push the whole VPS into swapping or make the kernel kill sshd or your database. With a limit, only that container is killed and restarted. A small app plus MariaDB on a 1 GB plan could look like this:
# /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: a hard memory cap. Setting both to the same value stops the container from using swap. Leave room for the OS: on 1 GB, keep the total of all limits around 600–700 MB.cpus:1.0means one full core. This keeps a busy container from pushing the whole VPS over the fair-use CPU average.restart: unless-stopped: containers come back after a crash or reboot, but not after you stop them on purpose.healthcheck: marks a container healthy or unhealthy, anddepends_onwaits for the database. The test command (herewget) must exist inside the image. Note that plain Docker does not restart an unhealthy container; it only reports the status.
Store secrets in an .env file next to compose.yaml, then start the 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
Step 4: Avoid the Firewall Bypass (UFW, firewalld and Docker)
This is the most common Docker security mistake. When you publish a port with -p 8080:8080 or "8080:8080", Docker adds its own NAT and forwarding rules. That traffic never passes the INPUT rules that UFW and firewalld manage, so the port is open to the whole internet even if ufw status says it is blocked.
Use these fixes, in order of preference:
- Publish on 127.0.0.1 (
"127.0.0.1:8080:8080", as in the compose file above). The port is reachable only from the VPS itself. - Put a reverse proxy in front. Nginx or Caddy on the host listens on 80/443, which you open in UFW or firewalld as usual, and forwards to
127.0.0.1:8080. You also get HTTPS in one place. - Do not publish internal services at all. Databases and caches only need the Compose network; containers in the same project reach each other by service name (
db:3306).
A minimal Nginx site for the app (add HTTPS with Let's Encrypt, as shown in our Nginx and Certbot guide):
# /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;
}
}
Check what is actually exposed:
# 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
If a port really must be public but only for certain addresses, add rules to the DOCKER-USER chain, which Docker evaluates before its own rules. --ctorigdstport matches the port as the client sent it, before Docker's address translation:
# 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 are not saved automatically and are lost on reboot, and a wrong rule can cut containers off from the network. Binding to 127.0.0.1 is simpler and survives reboots. For the host firewall itself, see our UFW and firewalld guide.Step 5: Keep the Disk Clean
Old images, stopped containers and build cache pile up quickly on a 10–40 GB disk. See what Docker uses first:
docker system df # images, containers, volumes, build cache
docker system df -v # per image / container / volume
df -h / && du -sh /var/lib/docker
Then clean up step by step, from safe to careful:
# 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 and docker system prune --volumes delete volumes that no container is using at that moment. If you stopped and removed a database container, its volume counts as unused and the data is gone. Always run docker volume ls first and back up anything you need.A weekly cleanup that never touches volumes:
# /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
If the disk is already full, see how to find and free disk space.
Step 6: Use Swap or zram on 512 MB and 1 GB Plans
A little swap protects a small VPS from sudden out-of-memory kills when several containers peak at once. A swap file on disk is the classic option (see how to add swap space). On Debian and Ubuntu, zram is a good extra: it keeps compressed swap in RAM, which is much faster and causes no disk I/O:
# 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
On Rocky Linux and AlmaLinux, use a swap file. Swap is a safety net, not extra RAM: if free -h shows swap in constant use, the plan is too small for your containers.
Step 7: Update Containers Safely
Pin image versions instead of :latest, so an update only happens when you decide, and you always know which version to roll back to:
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"
Update the Docker engine itself with the system packages. Thanks to live-restore, containers keep running during the upgrade:
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
Is a 512 MB plan enough for Docker?
For one or two light containers (a static site, a small proxy or bot), yes, with swap and strict memory limits. A database plus a web app is much more comfortable on 1–2 GB.
Why does my container keep restarting?
Check docker compose logs app and docker inspect $(docker compose ps -q app) --format '{{.State.OOMKilled}}'. If it says true, the container hit its memory limit: raise mem_limit, reduce the app's memory use, or upgrade the plan from the service page.
Can JUSTG help with my containers?
Problems with the VPS itself, such as network or console access, go to Technical support by ticket. JUSTG offers 24/7 AI support by ticket, and engineers follow up when a person is needed.