DevOps

Install Docker on a VPS and run your first container, plus the Docker Hub 403 fix in Iran

Installing Docker on a VPS: a terminal card with Docker Engine install commands, getting past the Docker Hub 403 error, starting a multi-service app with Docker Compose and binding a container port to localhost

Installing Docker on a VPS takes no more than three commands, but what separates that from a proper installation is everything those three commands do not show you: a container's published port slips past your firewall, Docker Hub turns away Iranian IP addresses with a 403 error, and container logs have no size limit by default. Docker packs an app and all its dependencies into one portable bundle, and an hourly cloud server is the ideal playground for it: a clean environment, usually ready in under a minute, and hourly billing that stops when you delete the server. In this guide we go from installation to Compose, the firewall, backups and keeping disk usage under control.

Why does Docker work so well on an hourly cloud server?

  • An always-clean environment: you create a fresh server, bring up your containers, test, and delete it, having paid for just a few hours.
  • Repeatable deployments: an image is a frozen copy of the entire environment. What ran on the test server comes up byte for byte on the production server, and a difference in PHP or libc versions between two machines will not catch you off guard.
  • Several apps on one server: a site that needs Node 22 and a bot that runs on Node 24 sit side by side without any tricks.
  • Easy rollback: if the new version is broken, you switch the image back to the previous tag. Rolling back a manual install is hard; rolling back a tag takes one line.

To be fair, Docker also adds a layer, and for a simple WordPress site a direct install is less hassle. It pays off when you run several services, or when you want to ship exactly what you tested.

Before you install Docker on a VPS: what does your server need?

The type of virtualization matters. To build containers, Docker works directly with the Linux kernel's namespaces and cgroups. On shared-kernel virtualization such as OpenVZ that access is closed off, and Docker either fails to start or behaves strangely. A KVM server has its own independent kernel; the difference between the two architectures is explained in our explainer on KVM and NVMe. Mehran Host cloud servers are KVM with NVMe disks, so you will not hit this obstacle.

You will need more disk than you think. A base image takes a few hundred megabytes, the build layer cache several gigabytes, and a database volume as much as your data, and they all pile up. For a multi-service environment, anything under 20 GB of disk is not realistic.

Take the CPU architecture seriously. An image built on an ARM Mac dies immediately on an x86 server with exec format error. The fix is a multi-architecture image, or building on the same architecture as the target.

Docker's official repository currently supports three Ubuntu LTS releases: 26.04, 24.04 and 22.04. For a new server, pick one of the first two, because standard support for 22.04 ends in 2027; after that it only receives security patches with an Ubuntu Pro subscription.

Installing Docker on Ubuntu

⚠️ On a server inside Iran, before the first command: Docker's sanctions block is not limited to Docker Hub. Both get.docker.com and the download.docker.com repository sit on the same CDN, which enforces the same block, and answer Iranian IP addresses with a 403 error and the message "The Amazon CloudFront distribution is configured to block access from your country". In other words, before you even get to docker pull, the installation step itself stops. Unlike the registry block, this one can be worked around with a sanctions-bypass DNS service (Iranian resolvers that answer sanctioned hostnames with a relay's address) or a domestic apt mirror.

The quick way: the official script

The fastest route is Docker's own install script:

curl -fsSL https://get.docker.com | sh

The script starts the service under systemd by itself; systemctl status docker should show active. The real test is running an image:

docker run hello-world

If you see the welcome message, the engine is running. On a server inside Iran, though, you get a 403 right here too. This time the installation is fine and the problem is the Docker Hub registry; the fix is in the next section.

⚠️ Docker's official documentation does not recommend this script for production environments: it sets up the repository so that every run fetches the latest version. It is great for a test server; for a real site, use the apt repository method.

The stable way: the official apt repository

You add Docker's GPG key and repository by hand, then install the five official packages:

apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

# ${UBUNTU_CODENAME:-$VERSION_CODENAME}: returns the right codename on Ubuntu derivatives too
cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$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

This is the deb822 format that the official documentation uses today. The one-line docker.list file still works but is being phased out; if you have one, apt modernize-sources converts it. The docker-compose-plugin package gives you the docker compose command (with a space, not a hyphen).

The version numbers depend on what the repository holds on the day; what matters is that both commands answer without an error:

docker --version
docker compose version

Docker version 29.8.0, build ...
Docker Compose version v5.5.1

Running Docker without sudo

So that you do not have to type sudo every time, add your own admin user to the docker group, then log out and back in once. In a root shell, write the username out explicitly, because there $USER means root:

usermod -aG docker mehran     # replace mehran with your own username

# or, from that user's own shell:
sudo usermod -aG docker $USER
⚠️ Membership in the docker group effectively grants root access, and the official documentation says so too: any member can start a container that mounts the server's entire filesystem. Add only your own admin user, never the user a web application runs as.

How do you get past the Docker Hub 403 error on a server in Iran?

To comply with US export control regulations, Docker blocks Iranian IP addresses on its registry, so the first docker pull ends like this:

docker pull nginx:alpine

Error response from daemon: error parsing HTTP 403 response body:
invalid character '<' looking for beginning of value:
"<html><body><h1>403 Forbidden</h1>
Since Docker is a US company, we must comply with US export
control regulations..."

Unlike the installation step, no DNS setting fixes this one: the registry decides based on the source IP address. You have three practical options:

1. Set up a registry mirror

Docker can use a "mirror": it fetches images from an intermediate server instead of Docker Hub. The mirror's address goes into /etc/docker/daemon.json:

{
  "registry-mirrors": ["https://mirror-address.example"],
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}
systemctl restart docker
docker info | grep -A1 "Registry Mirrors"

 Registry Mirrors:
  https://mirror-address.example/

Note two limits: registry-mirrors only works for Docker Hub images and has no effect on ghcr.io; and public mirrors come and go, so do not count on them for serious infrastructure.

2. Pull the image on a server abroad and transfer it

If you have a server outside the country, pull the image there and transfer it as an archive. This method depends on no mirror at all:

# name the target architecture explicitly; the source and target servers are different machines
docker pull --platform linux/amd64 nginx:alpine
docker save nginx:alpine | gzip > nginx-alpine.tgz
scp nginx-alpine.tgz root@IRAN_SERVER_IP:/root/

ssh root@IRAN_SERVER_IP 'gunzip -c /root/nginx-alpine.tgz | docker load'
Loaded image: nginx:alpine
💡 Since Docker 29, and only on fresh installs, the default image store is containerd (an upgraded server stays on the old store). The output of docker save is then an OCI archive, which a much older Docker will not load, so keep both ends on similar versions.

The same server abroad can also act as a private registry, so that the server in Iran pulls from it directly. If you do not have one, see our guide to getting a server outside Iran. At Mehran Host, international servers in Hetzner datacenters are ordered and paid for in Toman from the same wallet; pick the location with the lowest latency to your server in Iran.

3. Build the image on the server itself

If you have a Dockerfile and the base image is available, run the build on the server itself, bearing in mind that a build usually pulls packages from npm or pip as well.

💡 Docker Hub pull limits: sanctions aside, Docker Hub also caps downloads. According to the official documentation, the limit is 100 pulls per 6 hours for anonymous users (per IPv4 address, or per /64 subnet for IPv6) and 200 for a signed-in free account. On a CI server, sign in with docker login to double the limit.

Your first real container: a web server

docker run -d --name web -p 8080:80 nginx:alpine

What the command says: take the lightweight nginx:alpine image, run it in the background (-d) and open the container's port 80 on port 8080 of the server; http://IP:8080 is now live. A few everyday commands:

docker ps               # what is running?
docker logs -f web      # live log
docker exec -it web sh  # open a shell inside the container
docker stop web && docker rm web   # stop and remove

When troubleshooting, docker ps -a also shows stopped containers. A container that "won't start" has usually started, hit an error and died. The STATUS column tells you the exit code, and docker logs tells you why:

# the default output has seven columns; shortened here
docker ps -a --format "table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}"

CONTAINER ID   IMAGE          STATUS                     NAMES
9f2c1a4b7e83   myapp:1.0      Exited (1) 12 seconds ago  app
c41d8a9f0b62   nginx:alpine   Up 4 minutes               web

And if the host port is already taken, Docker fails with bind: address already in use; check what is sitting on it with ss -tlnp | grep 8080.

What is the difference between a Docker image, container, volume and network?

Most first-week confusion comes from mixing up these concepts, and the last column of the table matters most:

ConceptWhat is it?Command to inspect itWhat happens when the container is removed?
ImageA read-only template of the app's filesystem and configurationdocker imagesStays until you delete it yourself
ContainerA running instance of an image, with a temporary writable layerdocker ps -aThe container and its writable layer are deleted
VolumeStorage managed by Docker, outside the containerdocker volume lsStays; database data is safe here
bind-mountA folder on the server mounted inside the containerdocker inspectStays; it lives on the server's own filesystem
NetworkAn internal network on which containers find each other by service namedocker network lsStays; Compose creates its own

The table's hidden message is one that beginners learn the expensive way: anything you write to a container's writable layer (uploads, SQLite, app logs) is gone with the first docker rm. Important data belongs in a volume, and nowhere else.

Docker Compose: a multi-service app in one file

A real app has several parts, WordPress plus a database for example, and Compose defines them together. Assume compose.yaml sits in a folder named wpsite; the folder name later becomes the prefix of the volume names:

services:
  db:
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: strong-root-pass
      MARIADB_DATABASE: wp
      MARIADB_USER: wpuser
      MARIADB_PASSWORD: strong-user-pass
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 10

  wordpress:
    image: wordpress:7-php8.4-apache
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wp
      WORDPRESS_DB_USER: wpuser
      WORDPRESS_DB_PASSWORD: strong-user-pass
    volumes:
      - wp_data:/var/www/html
    depends_on:
      db:
        condition: service_healthy

volumes:
  db_data:
  wp_data:

Then:

docker compose up -d

A complete WordPress site with its database is up. docker compose down takes everything down (the data stays in the volumes), and docker compose logs -f wordpress shows the logs of one service. restart: unless-stopped also brings a service back after a reboot; unlike always, whatever you stopped on purpose stays stopped. Four differences between this file and the examples you find online are deliberate:

  • condition: service_healthy instead of a plain depends_on. The short form of depends_on only guarantees that the database container has started, not that it is ready to accept connections. On its first run the database spends a few dozen seconds on initial setup, and WordPress throws errors in the meantime.
  • A separate database user instead of root. If the app is ever compromised, the damage stays limited to that one database.
  • A pinned tag instead of latest. wordpress:7-php8.4-apache locks the PHP version, the web server and the WordPress major branch, but it is itself a floating tag that moves forward with every 7.x release; latest is looser still. If exactly what you tested must run, use a full tag such as wordpress:7.1.0-php8.4-apache, or pin the image by its digest (image@sha256:...).
  • 127.0.0.1 in front of the port. The reason is the subject of the next section.

Why doesn't your firewall block Docker's ports?

This is Docker's most dangerous trap on a public server. When you publish a port with -p, Docker writes its rules straight into the iptables nat table. In the PREROUTING chain the packet is translated to the container's internal IP, and from then on it is no longer addressed "to the server itself", so it never enters the INPUT chain, where UFW's usual rules sit, and passes through FORWARD instead. UFW has rules in FORWARD too, but Docker inserts its own rules above them, so the packet has already been accepted before it reaches UFW's rules. In the words of the official documentation, container traffic "gets diverted before it goes through the ufw firewall settings".

The practical result: you ran ufw deny 3306 and ufw status shows the rule, but if you published the database port with -p 3306:3306, it is reachable from the entire internet.

⚠️ The safest fix is not to publish the port at all. Services in one Compose file see each other by service name: WordPress connects to db:3306 without any port being opened on the host. For databases, caches and queues, never write a ports entry.

If a port must be open, but only from the server itself (for a host-level Nginx proxy, say), bind it to localhost:

docker run -d -p 127.0.0.1:8080:80 nginx:alpine   # only from the server itself
docker run -d -p 8080:80 nginx:alpine             # from the entire internet

And if you want firewall filtering to apply to container traffic as well, write the rules in the DOCKER-USER chain, not in UFW. That applies to the default backend, iptables; since Docker 29 there is also an experimental nftables backend (the firewall-backend option), which has no DOCKER-USER chain. We have collected the other layers of server hardening in our Linux server security checklist.

Where does your data live, and how do you back it up?

Anything that must survive the removal of a container lives in a volume or a bind-mount:

docker volume ls

DRIVER    VOLUME NAME
local     wpsite_db_data
local     wpsite_wp_data

Renaming the project folder makes Compose create new volumes, and your data appears to be "lost", while the old volume is still right where it was.

To back up a database, do not copy the volume's raw files: a copy taken while the database is running is not valid. The right way is a dump from inside the container, using the database's own tool:

mkdir -p /backup
cd /srv/wpsite            # the folder that holds compose.yaml
set -o pipefail           # run the script with bash
OUT=/backup/wp-$(date +%F).sql.gz

docker compose exec -T db sh -c \
  'export MYSQL_PWD="$MARIADB_ROOT_PASSWORD"; exec mariadb-dump -u root --single-transaction wp' \
  | gzip > "$OUT" || { rm -f "$OUT"; echo "dump failed"; exit 1; }

ls -lh "$OUT"

Two vital details. First, the password is read inside the container itself, where the MARIADB_ROOT_PASSWORD variable already exists, so it never appears in any argument on the host; if you pass it with -e MYSQL_PWD, that very command leaks the password in the host's ps output. Second, gzip -t only checks that the .gz file is intact, and a failed dump still produces a perfectly valid empty file. You have to check the exit code of the dump itself, and in a pipeline only set -o pipefail gives you that.

For static files, attach a temporary container to the volume and create an archive. Replace wpsite_ with your own project's prefix, because a wrong name produces no error: Docker simply creates a fresh, empty volume and the archive comes out empty.

docker run --rm -v wpsite_wp_data:/data -v /backup:/backup \
  alpine tar czf /backup/wp-files-$(date +%F).tgz -C /data .

Put both into a daily cron job, with one caution: docker compose looks for compose.yaml relative to the current directory, and cron runs in the home directory, so without a cd or -f /srv/wpsite/compose.yaml the job stops with no configuration file provided. And a backup you have never restored is not a backup: test the restore on a fresh hourly server.

Disk full? Rein in Docker's logs and images

The most common complaint about Docker servers after a few weeks: the disk has filled up for no apparent reason. There are two culprits.

Culprit one: container logs. The default driver is json-file, and its max-size is unlimited. There is no rotation at all, and a chatty container piles up months of logs. The fix is the same /etc/docker/daemon.json file:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

With this setting each container keeps at most three 10 MB files, but only newly created containers; existing ones have to be recreated (docker compose up -d --force-recreate).

Culprit two: images and the build cache. Every time you pull a new image, the previous version stays on disk without a name. Look at the situation first:

docker system df

TYPE            TOTAL   ACTIVE  SIZE     RECLAIMABLE
Images          14      3       6.2GB    4.8GB (77%)
Containers      5       3       128MB    41MB (32%)
Local Volumes   4       3       1.1GB    210MB (18%)
Build Cache     62      0       3.4GB    3.4GB (100%)

Then clean up:

docker image prune -a     # images that no container uses
docker builder prune      # the build cache
docker container prune    # stopped containers
⚠️ The --volumes switch of docker system prune removes only anonymous volumes; the official documentation says named volumes are left untouched, and -a does not change that. The real danger is docker volume prune --all, which removes Compose's named volumes too: a service that happens to be down counts as "unused".

Limiting memory use is also worthwhile, so that one runaway container cannot bring the whole server down through an out-of-memory (OOM) kill: --memory=512m on docker run or mem_limit: 512m in Compose, plus docker stats for live usage.

How do you put Nginx and SSL in front of your containers?

We bound the WordPress port to 127.0.0.1:8080, so how does a real visitor reach the site? Through an Nginx running on the server itself, not in a container, that proxies the requests:

server {
    listen 80;
    listen [::]:80;
    server_name example.com;
    client_max_body_size 64m;

    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;
    }
}

For now the file only covers port 80; Certbot adds listen 443 ssl and the certificate path by itself.

The four headers above are not optional. Without them, the app inside the container sees every visitor's IP as the Docker network's gateway: 172.17.0.1 on the default bridge, and usually 172.18.0.1 on a Compose network. X-Real-IP and X-Forwarded-For at least make the real IP available to the app; WordPress core still reads REMOTE_ADDR, and logging the real IP takes a plugin. X-Forwarded-Proto, however, is read by the official WordPress image itself, and it prevents the redirect loop you get when TLS ends at Nginx.

Two common errors with this setup: 502 Bad Gateway means the container behind Nginx is down or listening on a different port; 413 Request Entity Too Large means Nginx's default 1 MB limit has blocked an upload. Keep client_max_body_size in the block above in line with upload_max_filesize and post_max_size in the WordPress container.

We cover installing Nginx and a free certificate with Certbot in our guide to Nginx on Ubuntu. The advantage of this setup: automatic certificate renewal stays outside the containers, and updating the app has nothing to do with SSL.

A suggested workflow with an hourly server

A real-world scenario that combines Docker with hourly billing:

  1. Morning: you create a server with 4 GB of RAM (provisioning is automatic), install Docker and pull compose.yaml from Git;
  2. docker compose up -d: the project's full environment comes up, and you develop and test all day;
  3. Evening: push to the registry/Git, export from the volumes, delete the server;
  4. The cost of the whole day: a few hours of server time, not a month.

What makes this workflow possible is that the whole environment is captured in two text files: compose.yaml and the setup script. As long as they are in Git, the server is a disposable resource.

If you do not have a server to practice on yet, create an hourly cloud server. You can work through this entire tutorial on it in one afternoon and then delete it so the charges stop; powering it off lowers the hourly rate but does not bring it to zero.

Frequently asked questions

What specs does a VPS need to run Docker?

The server must be KVM-based, so that it has its own kernel; on shared-kernel virtualization such as OpenVZ, Docker usually will not run. The Docker engine itself is light, but images, the build cache and volumes all take space: anything under 20 GB of disk is not realistic for a multi-service environment, and 2 GB of RAM is a comfortable minimum for one app plus its database.

Why does docker pull return a 403 error on a server in Iran?

Docker blocks IP addresses in Iran and several other countries on its registry to comply with US export controls, so the registry answers with a 403. This comes from the registry, not your installation or network, and changing DNS does not fix it, unlike the installation-step 403 from download.docker.com, which a domestic DNS service can work around. Three practical options: a registry mirror in daemon.json, pulling the image on a server abroad and moving it with docker save and docker load, or building the image on the server itself.

Why is a container's port open to the internet even though my firewall is on?

Because when you publish a port with -p, Docker writes its own rules into the iptables nat table, and packets are diverted to the container in the PREROUTING chain without ever entering the INPUT chain, where UFW's rules live. UFW also has rules in FORWARD, but Docker inserts its own above them. The fix is not to publish ports for internal services, since services in one Compose file see each other by name; if a port is needed, bind it to localhost only with 127.0.0.1:8080:80.

What is the difference between docker compose and docker-compose?

The current, supported version is docker compose with a space, which is installed as an official plugin through the docker-compose-plugin package. The old docker-compose with a hyphen was a separate tool written in Python and is no longer developed. The version key at the top of the configuration file is obsolete too: newer releases ignore it with a warning, so it is best to remove it.

Does removing a container delete the database's data?

Not if the database lives on a volume or a bind-mount: removing a container deletes only its temporary writable layer, and the volume stays untouched for the next container to attach to. But if you never defined a volume, the data sits inside the container itself and is gone for good with docker rm. Two other risks: docker volume prune --all, which also removes named volumes that have no container, and renaming the project folder, which makes Compose create new volumes.

Related tutorials

Ready for some hands-on practice?

A Mehran Host hourly cloud server is ready in under 60 seconds. Practice as much as you like and pay only for the hours you use. Estimate the cost with the pricing calculator before you sign up.