Docker on a VPS from Scratch: Installation, Compose, Updates, and Volume Backups
TL;DR
Docker on a VPS lets you run websites, databases, bots, VPNs, Git services, and other applications in isolated containers while managing the entire infrastructure through Docker Compose files. In this guide, you will prepare an Ubuntu server, install Docker Engine and Compose, deploy a test application with HTTPS, configure automatic updates, and set up backups for Docker volumes.
- For most small projects, a VPS with 2 vCPU, 4 GB RAM, and a 50–80 GB NVMe disk is sufficient.
- Docker Engine is installed from the official APT repository, while Compose is used as the built-in
docker composeplugin. - All application settings, networks, volumes, and dependencies are described in
compose.yaml. - Secrets are stored in the
.envfile, which should not be added to Git. - For public services, HTTPS can be conveniently provided through Caddy with automatic Let’s Encrypt certificates.
- You should back up data rather than containers: Docker volumes, database dumps, configs, and the
.envfile.
What We Are Setting Up and Why
Docker is a platform for running applications in containers. A container includes the application, its libraries, and system dependencies, but uses the host operating system kernel. Unlike a virtual machine, a container does not require a separate guest OS, starts quickly, and consumes less memory.
In practice, Docker on a VPS is useful when a server needs to run multiple services without manually installing dependencies on the system. For example, you can host WordPress, PostgreSQL, Redis, Mattermost, n8n, Gitea, Vaultwarden, a Python or Node.js API, and a reverse proxy with TLS certificates at the same time. Each service gets its own container, network, and persistent storage.
This tutorial will deploy a basic yet production-like setup:
- Docker Engine on Ubuntu 24.04 LTS;
- Docker Compose v2 for stack management;
- a demonstration Nginx web application;
- Caddy as a reverse proxy and automatic HTTPS certificate manager;
- a named Docker volume for persistent data;
- a separate internal Docker network;
- a Docker volumes backup script;
- a plan for safely updating images and containers.
What You Will Have in the End
After completing the steps, you will have a server where applications are described declaratively. Instead of a long sequence of commands, you store the configuration in the compose.yaml, Caddyfile, and .env files. Moving a service to another VPS comes down to copying these files, restoring the volume backup, and running docker compose up -d.
This approach is especially useful if you run self-hosted services. Containers can be stopped, removed, and recreated without losing user data, provided that the data is stored in Docker volumes or mounted host directories rather than inside the container's filesystem.
Docker Compose: Why You Need It
A single container can be started with docker run, but this quickly becomes inconvenient for a persistent service. You need to remember port, network, environment variable, restart policy, volume, and resource limit settings. Docker Compose stores all of this in a YAML file.
For example, an application and database can be described in one file. With a single command, Compose creates the network, prepares volumes, downloads images, and starts services in the correct order. This also makes the configuration reviewable in Git and understandable to another administrator.
Self-Hosted on a VPS or a Managed Service
| Criterion | Self-hosted Docker on a VPS | Managed platform |
|---|---|---|
| System control | Complete: OS, network, data, image versions | Limited by the provider's capabilities |
| Maintenance | The administrator is responsible for updates and backups | Some tasks are handled by the platform |
| Cost of multiple services | Often more cost-effective on a single VPS | May increase with every service and database |
| Flexibility | Almost any OCI image can be run | Depends on the supported stack |
| Fault tolerance | Must be designed independently | Often included in more expensive plans |
Self-managed Docker on a VPS is suitable if you are prepared to perform basic Linux operations: update packages, check logs, test updates, and maintain independent backups. For a single critical application with high-availability requirements, using a managed database or cloud platform may be more sensible. However, for small teams, MVPs, personal services, and several related applications, Docker Compose on a VPS remains a simple and controllable option.
What VPS Configuration Is Needed for This Task
Docker itself hardly determines server requirements: containers consume resources. The minimum configuration depends on the number of applications, the database type, the volume of uploaded files, and expected traffic. The main mistake is choosing a VPS based only on the number of cores and forgetting about RAM, disk IOPS, and spare space for images and backups.
| Scenario | CPU | RAM | NVMe disk | Network |
|---|---|---|---|---|
| Testing, a bot, one small website | 1 vCPU | 2 GB | 25–40 GB | 100 Mbps |
| 2–5 small containers, PostgreSQL, reverse proxy | 2 vCPU | 4 GB | 50–80 GB | 100 Mbps–1 Gbps |
| CRM, Mattermost, n8n, multiple web services | 4 vCPU | 8 GB | 120–200 GB | 1 Gbps |
| High-load database, CI builds, many users | 8 vCPU and above | 16 GB and above | 300 GB and above | 1 Gbps and above |
For this guide and most first Docker projects, choose 2 vCPU, 4 GB RAM, 60–80 GB NVMe, and a connection of at least 100 Mbps. This capacity allows you to run a reverse proxy, one or two web services, PostgreSQL or MariaDB, and still leave room for images. As one option, you can choose a VPS with the specified characteristics, but before ordering, verify that the plan includes a public IPv4 address, SSH access, and sufficient disk space.
Why Docker Quickly Uses Disk Space
Images consist of layers. After several updates and experiments, unused images, build cache, and stopped containers remain on the server. In addition, databases and user files are stored in volumes. Do not plan to use 100% of the disk: leave at least 20–30% free space, otherwise PostgreSQL, the Docker log, or an OS update may fail.
Disk usage should be checked regularly:
# Показывает использование диска Docker: образы, контейнеры, volumes и build cache
docker system df
# Показывает свободное место в файловой системе VPS
df -h
When You Need a Dedicated Server Rather Than a VPS
A VPS is sufficient for most web applications, internal services, APIs, and small databases. A dedicated server becomes justified when guaranteed CPU and disk performance, a very large amount of local storage, high database load, intensive CI compilation, or dozens of actively running containers are required.
You should also consider a dedicated server for tasks with sustained high load: video encoding, game servers with many concurrent players, blockchain indexers, large Elasticsearch clusters, and databases with a high number of IOPS. Do not move to a dedicated server just because Docker is running slowly: first check RAM limits, swap, disk latency, database settings, and actual CPU metrics.
How to Choose a Location
Location affects latency to users, personal data storage requirements, and connection speed to external services. A server for a team in Europe is usually placed closer to team members; an API for clients in a specific region is placed in that same region. For private services, consider your own latency and legal requirements.
Before publishing a website, make sure that the domain can be pointed to the VPS IPv4 address with an A record. For Caddy to issue certificates automatically, the server must be accessible from the internet on ports 80 and 443. If these ports are blocked by the external network or DNS points to another address, HTTPS will not be issued.
Server Preparation
Ubuntu Server 24.04 LTS is used below—a stable option for Docker in 2026. The instructions are also similar for Ubuntu 22.04 LTS, Debian 12, and Debian 13, but package names and firewall rules may differ slightly. Connect to the new server as root only for the initial setup.
Create a regular user with sudo
Do not work as root permanently. Create an administrator, add them to the sudo group, and then use this account to maintain the server. The examples use the name deploy; replace it with your own.
# Creates the deploy user with a home directory and prompts for a password
adduser deploy
# Grants the user permission to run administrative commands through sudo
usermod -aG sudo deploy
Configure SSH keys
Generate an Ed25519 key on your local computer if you do not already have one. Do not transfer the private key file to the server or publish its contents. A key passphrase protects it if your laptop is lost.
# Run on the local computer: creates an Ed25519 SSH key pair
ssh-keygen -t ed25519 -a 100 -C "admin@my-laptop"
# Copies the public key to the deploy account on the VPS
ssh-copy-id deploy@SERVER_IP
# Verifies key-based login; after this, the root password is no longer needed for regular work
ssh deploy@SERVER_IP
Open a second SSH connection and make sure key-based login works before disabling password authentication. Otherwise, you may lose access to the server.
# Opens the SSH server settings
sudo nano /etc/ssh/sshd_config.d/99-hardening.conf
Add the following parameters to the open file:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
# Checks the SSH configuration syntax before restarting
sudo sshd -t
# Applies the new SSH configuration without rebooting the server
sudo systemctl reload ssh
Update the system and install basic utilities
First, update the package indexes and install security fixes. After major kernel updates, reboot the server during a convenient maintenance window. To indicate that a reboot is required, Ubuntu creates the /var/run/reboot-required file.
# Updates Ubuntu packages to the latest versions
sudo apt update && sudo apt full-upgrade -y
# Installs utilities for downloading keys, diagnostics, and archives
sudo apt install -y ca-certificates curl gnupg lsb-release nano \
unzip jq htop ncdu rsync cron fail2ban ufw
# Shows whether a reboot is required after a kernel or library update
test -f /var/run/reboot-required && cat /var/run/reboot-required || echo "Reboot not required"
Configure the firewall
UFW blocks incoming connections except those explicitly allowed. First allow SSH, then HTTP and HTTPS. If you use a non-standard SSH port, allow that specific port before enabling the firewall.
# Allows SSH to avoid losing remote access
sudo ufw allow OpenSSH
# Allows public HTTP and HTTPS for Caddy and web applications
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enables the firewall and displays active rules
sudo ufw enable
sudo ufw status verbose
Do not expose a database port, such as PostgreSQL 5432 or MariaDB 3306, to the internet unless there is a genuine need. Containers on the same Docker network can communicate using the internal service name. Expose only the reverse proxy and services that truly need to be accessible to users.
Enable Fail2ban
Fail2ban reads SSH logs and temporarily blocks IP addresses with repeated login failures. It does not replace SSH keys, but it reduces noise from automated scanners.
# Starts Fail2ban now and on every VPS boot
sudo systemctl enable --now fail2ban
# Verifies that the SSH jail is active and displays blocked addresses
sudo fail2ban-client status sshd
Docker directly manages iptables/nftables rules for published ports. Therefore, do not rely solely on UFW for absolute container isolation. The primary protection is to avoid publishing unnecessary ports through Compose, use separate networks, and update images regularly.
Software Installation — Step by Step
On Ubuntu, do not install the docker.io package from the standard repository if you want current Docker Engine versions. Use the official Docker repository. As of 2026, the current Docker Engine branch is 29.x, and Docker Compose is distributed as a v2 plugin and run with the docker compose command.
Remove conflicting packages
If Docker was already installed on the VPS from the system repository or a test configuration, remove conflicting packages. The command does not automatically remove your Docker volumes, but this is usually irrelevant on a new server.
# Removes old or conflicting Docker packages, if installed
sudo apt remove -y docker.io docker-compose docker-compose-v2 \
docker-doc podman-docker containerd runc 2>/dev/null || true
Add the official Docker GPG key and repository
APT verifies package signatures using the Docker key. The commands below create a secure directory for keys, download the key, and automatically detect the architecture and Ubuntu codename.
# Creates a directory for third-party APT repository keys
sudo install -m 0755 -d /etc/apt/keyrings
# Downloads the official Docker repository GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# Makes the key readable by APT
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Adds the official stable Docker repository for the current Ubuntu version
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Updates the package list after adding the new repository
sudo apt update
Install Docker Engine, Buildx, and Compose
Install the components as a single set. The docker-buildx-plugin package is required for modern image builds, and docker-compose-plugin adds the docker compose subcommand. The containerd.io package provides the container runtime.
# Installs Docker Engine 29.x, CLI, containerd, Buildx, and Compose v2
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Enables Docker and containerd at startup
sudo systemctl enable --now docker containerd
# Checks the installed engine, Compose, and Buildx versions
docker version
docker compose version
docker buildx version
Allow the user to work with Docker without sudo
By default, the /var/run/docker.sock socket is accessible to root. Adding a user to the docker group allows them to manage containers without sudo. It is important to understand that a member of this group effectively gains root access to the server, as they can mount system directories in a container. Do not add unprivileged users to it.
# Adds the current user to the docker group
sudo usermod -aG docker "$USER"
# End the SSH session and reconnect, then verify Docker access
exit
After reconnecting, run the test. The hello-world container will be downloaded from Docker Hub, started, and then exit.
# Verifies that Docker works without sudo and can download images
docker run --rm hello-world
# Displays the Docker service status
systemctl status docker --no-pager
Check network settings and enable log rotation
By default, containers write logs to JSON files in the /var/lib/docker/containers directory. Without rotation, a single faulty service can fill the entire disk. Configure a reasonable limit for new containers. Existing containers will retain the old setting until they are recreated.
# Creates Docker daemon configuration with container log rotation
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
EOF
# Validates the JSON and restarts Docker to apply the configuration
sudo jq empty /etc/docker/daemon.json
sudo systemctl restart docker
# Verifies that Docker responds again after the restart
docker info --format '{{.ServerVersion}}'
The live-restore parameter helps already running containers survive a Docker daemon restart. This does not eliminate the need to test updates: network changes, kernel updates, or an incompatible image may still require a maintenance window.
Create a project workspace structure
Do not store production Compose projects in a random user's home directory or in /tmp. A convenient location is /opt/stacks. Restrict access: configurations often contain domains, passwords, and API keys.
# Creates a shared directory for Compose projects
sudo mkdir -p /opt/stacks/demo-app
# Assigns the directory to the deploy user; replace the name if necessary
sudo chown -R deploy:deploy /opt/stacks
# Sets secure permissions on the project directory
chmod 750 /opt/stacks /opt/stacks/demo-app
# Changes to the directory for the future Docker Compose stack
cd /opt/stacks/demo-app
Docker, Compose, and HTTPS Configuration
Now let us create a service accessible via a domain name over HTTPS. The example uses Nginx as a simple application and Caddy as a reverse proxy. In a real project, instead of Nginx, you may use your API, WordPress, Gitea, Nextcloud, or another container.
Before starting, create a DNS A record, for example app.example.com, pointing to the public IPv4 address of the VPS. Replace app.example.com and the email address with your own values. You can check DNS with dig +short app.example.com: the result must match the server IP.
Create an environment variables file
The .env file stores values that should not be duplicated in YAML: the domain, email for notifications, and secrets. Set permissions to 600. If the project is stored in Git, add .env to .gitignore, and keep only .env.example without real secrets in the repository.
# Создаёт файл окружения с параметрами конкретного сервера
cat > /opt/stacks/demo-app/.env <<'EOF'
DOMAIN=app.example.com
[email protected]
EOF
# Ограничивает чтение файла текущим владельцем
chmod 600 /opt/stacks/demo-app/.env
# Создаёт шаблон переменных для Git без приватных данных
cat > /opt/stacks/demo-app/.env.example <<'EOF'
DOMAIN=app.example.com
[email protected]
EOF
Prepare the Caddyfile
Caddy automatically obtains and renews TLS certificates through Let’s Encrypt or another compatible ACME certificate authority. For this, ports 80 and 443 must be accessible externally, and DNS must point to the server. Caddy will redirect HTTP to HTTPS and forward requests to the application container.
# Создаёт конфигурацию reverse proxy Caddy
cat > /opt/stacks/demo-app/Caddyfile <<'EOF'
{
email {$ACME_EMAIL}
}
{$DOMAIN} {
encode zstd gzip
reverse_proxy app:80
header {
-Server
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output stdout
format json
}
}
EOF
Within the Docker network, Caddy connects to the app name rather than an IP address. Docker DNS automatically maps this name to the service container. Nginx port 80 is not published externally: it is available only to the reverse proxy.
Create compose.yaml
The Compose file defines two services, a named volume, and an internal network. The Caddy image stores certificates in the caddy_data volume; without it, Caddy will lose ACME data after the container is recreated and may request new certificates too frequently. The app_content volume demonstrates persistent application file storage.
services:
caddy:
image: caddy:2.10-alpine
container_name: demo-caddy
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "443:443"
volumes:
- caddy_data:/data
- caddy_config:/config
- ./Caddyfile:/etc/caddy/Caddyfile:ro
networks:
- frontend
healthcheck:
test: ["CMD", "caddy", "version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
app:
image: nginx:1.28-alpine
container_name: demo-app
restart: unless-stopped
volumes:
- app_content:/usr/share/nginx/html
networks:
- frontend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 256M
networks:
frontend:
driver: bridge
volumes:
caddy_data:
caddy_config:
app_content:
The deploy.resources.limits.memory field is supported by current Docker Compose and limits container memory. Limits are especially useful for secondary services: one leaking process should not consume all VPS RAM. For PostgreSQL, Java applications, and Elasticsearch, limits must be selected according to the documentation for the specific product.
Add a start page to the Docker volume
A named volume cannot be edited with a regular editor on the host without locating Docker’s internal path. For the initial write, you can run a temporary Alpine container and mount the volume into it. This method is also useful for diagnosing files in a volume.
# Переходит в каталог проекта
cd /opt/stacks/demo-app
# Проверяет итоговую конфигурацию после подстановки .env
docker compose config
# Создаёт volume и записывает в него тестовую HTML-страницу
docker run --rm -v demo-app_app_content:/data alpine:3.21 \
sh -c 'cat > /data/index.html <<EOF
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Docker VPS</title></head>
<body><h1>Docker Compose работает</h1><p>Страница отдана через Caddy и HTTPS.</p></body>
</html>
EOF'
Compose builds the volume name from the project directory name and the volume name. In this example, the directory is named demo-app, so the result is demo-app_app_content. You can check the exact name with docker volume ls.
Start the stack and check the containers
# Скачивает образы и запускает все сервисы в фоновом режиме
docker compose up -d
# Показывает состояние контейнеров, опубликованные порты и health status
docker compose ps
# Показывает последние 100 строк журналов Caddy
docker compose logs --tail=100 caddy
# Проверяет ответ reverse proxy локально с нужным Host-заголовком
curl -I -H "Host: ${DOMAIN}" http://127.0.0.1
If DNS is already configured, check the certificate and response code from an external network:
# Проверяет HTTPS, цепочку сертификата и HTTP-заголовки домена
curl -Iv "https://${DOMAIN}"
# Проверяет, что порт 443 действительно слушается на VPS
sudo ss -tulpn | grep ':443'
The expected result is HTTP/2 200 or HTTP/1.1 200 OK. On the first start, certificate issuance may take several seconds. Check docker compose logs -f caddy if Caddy reports a domain validation issue.
Useful daily Docker Compose commands
| Command | Purpose |
|---|---|
docker compose ps |
Status of services in the current project |
docker compose logs -f app |
View application logs in real time |
docker compose restart app |
Restart a single service |
docker compose exec app sh |
Open a shell inside a running container |
docker compose pull |
Download new image versions without starting them |
docker compose down |
Stop and remove containers and the network, but not volumes |
Do not run
docker compose down -von a production project unless you understand the consequences. The-vflag removes named volumes, and databases, uploaded files, and certificates may disappear with them.
Backups and Maintenance
A container is a reproducible part of an application and usually does not need to be backed up. For recovery, Compose files, secrets, volume data, database dumps, and user uploads are more important. The rule “if there is a volume, there is a backup” should be mandatory for every production service.
What exactly needs to be backed up
compose.yaml,Caddyfile, application configuration files, and environment templates.- The
.envfile in encrypted or strictly protected storage. - Named Docker volumes containing application data.
- Logical dumps of PostgreSQL, MariaDB/MySQL, and other databases.
- Bind mount directories if data is mounted from the host.
- A list of images and versions if you use custom builds.
For a database, it is preferable to create a standard logical dump using pg_dump or mariadb-dump rather than copying its files while it is running. A file archive of a live PostgreSQL volume may be inconsistent. Either stop the database during the file backup or use physical backup tools recommended by the DBMS developers.
Install Restic
Restic is a convenient utility for deduplicated, encrypted, and incremental backups. It supports S3-compatible storage, SFTP, a local directory, and many cloud backends. The example below uses S3-compatible storage; access variables must not end up in Git or shell history.
# Installs Restic from the Ubuntu repository
sudo apt install -y restic
# Creates a directory for scripts and temporary backup archives
sudo mkdir -p /opt/backups/docker-volumes
sudo chown -R deploy:deploy /opt/backups
# Creates a file with access parameters for remote S3 storage
nano /opt/backups/restic.env
Fill in /opt/backups/restic.env with your values. Use a separate bucket and separate access keys restricted to that bucket only. The Restic repository password must be a long random value and stored separately from the server: without it, data recovery is impossible.
export RESTIC_REPOSITORY="s3:https://s3.example.net/docker-vps-backups"
export RESTIC_PASSWORD="CHANGE_TO_A_LONG_RANDOM_RESTIC_PASSWORD"
export AWS_ACCESS_KEY_ID="CHANGE_ME"
export AWS_SECRET_ACCESS_KEY="CHANGE_ME"
# Restricts access to keys and password to the file owner
chmod 600 /opt/backups/restic.env
# Loads variables and initializes the encrypted Restic repository once
source /opt/backups/restic.env
restic init
Create a Docker volumes backup script
The script below archives the specified volumes through a temporary Alpine container, adds the project configuration, and sends the result to Restic. It is intended for small and medium-sized volumes. For large databases, add a separate database dump before creating the archive.
cat > /opt/backups/backup-demo-app.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="/opt/stacks/demo-app"
BACKUP_DIR="/opt/backups/docker-volumes"
DATE="$(date +%F_%H-%M-%S)"
ARCHIVE="${BACKUP_DIR}/demo-app-volumes-${DATE}.tar.gz"
source /opt/backups/restic.env
mkdir -p "${BACKUP_DIR}"
docker run --rm \
-v demo-app_app_content:/volumes/app_content:ro \
-v demo-app_caddy_data:/volumes/caddy_data:ro \
-v demo-app_caddy_config:/volumes/caddy_config:ro \
-v "${BACKUP_DIR}:/backup" \
alpine:3.21 \
sh -c "tar -czf /backup/$(basename "${ARCHIVE}") -C /volumes ."
restic backup \
"${ARCHIVE}" \
"${PROJECT_DIR}/compose.yaml" \
"${PROJECT_DIR}/Caddyfile" \
"${PROJECT_DIR}/.env" \
--tag docker-demo-app \
--tag "${DATE}"
rm -f "${ARCHIVE}"
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic check
EOF
# Makes the script executable
chmod 700 /opt/backups/backup-demo-app.sh
# Runs the first backup manually and checks for errors
/opt/backups/backup-demo-app.sh
In this example, Caddy volumes are also saved. They do not contain critical business data, but backing them up reduces the number of repeated certificate operations during disaster recovery. If you use PostgreSQL, add the command docker compose exec -T db pg_dump -U USER DATABASE > /opt/backups/db.sql before archiving and include the dump file in restic backup.
Set up cron
First run the script manually, then add it to cron. Do not set up automation until you verify that the first backup is visible in the remote repository and the restic snapshots command shows a snapshot.
# Opens the deploy user's crontab
crontab -e
Add a daily run at 03:30. Output redirection saves a log that can be used to diagnose errors.
30 3 * /opt/backups/backup-demo-app.sh >> /opt/backups/backup.log 2>&1
# Shows created snapshots in the remote repository
source /opt/backups/restic.env
restic snapshots
# Checks the contents of the latest snapshot without restoring it
restic ls latest | head -n 50
Test recovery
A backup that has never been restored cannot be considered verified. At least once a quarter, deploy a test VPS or use a separate directory, restore a snapshot, and make sure the application starts. Do not restore a test archive over a production volume without stopping services and understanding the consequences.
# Restores the latest Restic snapshot to a separate directory for verification
mkdir -p /tmp/restic-restore-test
source /opt/backups/restic.env
restic restore latest --target /tmp/restic-restore-test
# Lists restored configuration files and the volume archive
find /tmp/restic-restore-test -maxdepth 3 -type f | sort
Safely update Docker and containers
Separate updates for the OS, Docker Engine, and application images. Do not update everything at once during working hours. First read the application release notes, create a fresh backup, pull images, then recreate services. It is better not to use the latest tag in production: pin a major or specific tested version, for example nginx:1.28-alpine.
# Changes to the Compose project directory
cd /opt/stacks/demo-app
# Creates a manual backup before the update
/opt/backups/backup-demo-app.sh
# Pulls new images without changing running containers
docker compose pull
# Shows which images are now used and their sizes
docker compose images
# Recreates services only when the image or configuration has changed
docker compose up -d --remove-orphans
# Checks status and recent errors after the update
docker compose ps
docker compose logs --tail=100
For stateless applications, an update usually resembles a rolling procedure: multiple replicas behind a reverse proxy are updated one at a time. Standard Docker Compose on a single VPS does not provide a full rolling update with orchestrator guarantees, so for a single container, expect a short interruption during recreation. Perform database updates, schema migrations, and major updates in a maintenance window with a tested rollback plan.
# Updates Docker Engine and Compose packages from the official APT repository
sudo apt update
sudo apt install --only-upgrade -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Removes only unused build cache; volumes and running images are not affected
docker builder prune -af
Do not blindly run docker system prune -a --volumes. This command can remove unused images, networks, and volumes; a mistakenly marked volume with important data may become unrecoverable without a backup. First use docker system df and remove specific unnecessary resources.
Troubleshooting + FAQ
Docker returns “permission denied while trying to connect to the Docker daemon socket”
The error means that the current user does not have access to the Docker socket. Check groups with the id command: docker should be among them. If the group is missing, run sudo usermod -aG docker $USER, completely exit the SSH session, and reconnect. Do not fix the issue by permanently using sudo chmod 666 /var/run/docker.sock: this grants Docker access to all system users and creates a serious security risk.
The container keeps restarting, and docker compose ps shows Restarting
First read the log for the specific service: docker compose logs --tail=200 app. Common causes include a missing environment variable, incorrect database password, an occupied port, a migration error, or an unsupported image architecture. Check the final variable substitution using docker compose config. Also check memory usage with free -h and system messages with dmesg -T | grep -i oom: the process may have been killed by the OOM killer.
The Caddy HTTPS certificate is not issued, or the site opens over HTTP
Check that the domain A record points to the VPS IP: dig +short ваш-домен. Make sure ports 80 and 443 are allowed in UFW, published in compose.yaml, and not occupied by another process: sudo ss -tulpn | grep -E ':80|:443'. Then open docker compose logs caddy. Common causes include DNS proxying through a third-party service, an AAAA IPv6 record with an incorrect address, or ports blocked at the provider network level.
The VPS is out of space even though there is little application data
Start with df -h and docker system df -v. The latter command shows the sizes of images, volumes, containers, and build cache. Check logs in docker compose logs and the size of the /var/lib/docker directory. If the issue is outdated build cache, it is safer to start with docker builder prune -af. Do not remove volumes with the prune command until you know which project they belong to and a fresh backup has been verified.
The application cannot connect to PostgreSQL or Redis within Compose
Containers must access each other by service name, for example postgres://db:5432/app, rather than through localhost. Inside a container, localhost points to that same container. Check that both services are on the same Compose network using docker compose exec app getent hosts db. If the database is still starting, add a healthcheck and connection retry logic to the application: depends_on does not replace database readiness for queries.
What is the minimum suitable VPS configuration?
For learning, one static site with Caddy, and a small bot, 1 vCPU, 2 GB RAM, and 25–40 GB SSD or NVMe are sufficient. For a real small project with a database, start with 2 vCPU, 4 GB RAM, and 50–80 GB NVMe. If you use PostgreSQL, n8n, Mattermost, a Java application, or multiple services, 8 GB RAM will be noticeably more reliable. Always leave free space for Docker images, logs, and backups.
Which should I choose for this task: VPS or dedicated?
For Docker Compose, personal services, MVPs, small SaaS applications, and team tools, a VPS is usually sufficient. It deploys faster, is easier to scale by plan, and costs less under moderate load. A dedicated server is needed for sustained high CPU load, a large number of disk operations, large databases, CI builds, or requirements for guaranteed performance. First measure actual usage with docker stats, htop, and disk metrics rather than choosing a dedicated server “just in case.”
Can all containers be updated automatically with Watchtower?
Technically, yes, but uncontrolled updates are risky for a production system. A new image may require a database migration, change environment variables, or contain an incompatible release. It is safer to configure notifications about new versions, update images manually during a maintenance window, and create a backup before recreation. Automatic updates are acceptable for non-critical stateless services, but even there, pin image versions and monitor logs after updating.
Conclusions and Next Steps
You now have a basic production Docker setup on a VPS: a secured server, Docker Engine, a Compose project, HTTPS through Caddy, persistent volumes, and encrypted backups. This foundation is sufficient to host most self-hosted applications without manually installing dependencies in Ubuntu.
- Add monitoring: node_exporter, cAdvisor, Uptime Kuma, or an external HTTP monitoring service.
- For each new project, create a separate directory in
/opt/stacks, a separate Compose file, a separate.env, and a separate backup plan. - As load grows, move the database to a separate server or managed solution, configure metrics, resource limits, and regular test restoration of backups.
The main maintenance rule remains simple: keep configuration reproducible, secrets separate and protected, data in volumes or explicit directories, and begin every update with a fresh verifiable backup.