VPS.TC
| $
Server Status
Turkey Istanbul, Türkiye
Active
USA New York, USA
Active
Cart Total:
View Cart
How to Install Docker on a VPS and Run Your First Container
How To?

How to Install Docker on a VPS and Run Your First Container

Avatar of Defne Defne August 29, 2026 13 min read 0 Comments
Share:

Docker on a VPS: the installation is the easy part

When I connect to a new VPS, I do not start by copying a Docker command from a browser tab. I first ask where the container will listen, where its data will live, and what will happen when the disk gets full. Docker packages an application and its dependencies neatly, but it does not secure the host, monitor the workload, or create backups for you.

I will use Ubuntu 22.04 or 24.04 LTS in the examples below. The commands are similar on Debian 12, but do not blindly use the Ubuntu repository there; repository URLs and package instructions must match the distribution. We will install Docker Engine, run a small Nginx container, and test it from outside the VPS.

Start by checking the machine itself. The Docker daemon is rarely the main consumer of resources; the applications inside the containers usually decide how much RAM, CPU, and disk you need. A database, web server, and monitoring stack on one small VPS can become crowded surprisingly quickly.

🚀 Boost Your Speed with VPS Server!

Speed up your projects with high-performance SSD storage and 99.9% uptime guarantee.

Get Started
cat /etc/os-release
uname -a
nproc
free -h
df -h

An empty swap configuration shown by free -h is not automatically wrong. On a low-memory VPS, though, a short spike can affect both the containers and the host. I install basic monitoring on new servers before adding workloads, then watch what the machine actually does. The preparation steps in Launch a Secure VPS in 30 Minutes | Pro Admin Guide are useful for that first pass.

Check for an older installation first

Ubuntu’s repository and a previously added Docker repository can leave different packages on the same machine. If an earlier installation was interrupted, inspect the current state before changing anything.

docker --version 2>/dev/null || true
dpkg -l | grep -E 'docker|containerd' || true
systemctl status docker --no-pager 2>/dev/null || true

On a new VPS with no workloads, removing the packages below is normally safe. Do not run this casually on a server that already has containers, images, or volumes. Removing packages does not necessarily remove Docker’s data directories, but cleanup without a rollback plan is still bad administration.

☁️ Gain Flexibility with Cloud Server!

Experience the power of cloud with scalable resources and instant backups.

Explore
sudo apt remove -y docker.io docker-doc docker-compose podman-docker containerd runc

I once found an old Docker installation on a machine I thought was a test server and nearly removed it immediately. A second check of hostname showed that it was a helper server close to production. It was not the staging box in my head.

That mistake is why I type the hostname before a destructive command and keep hostnames visible in production shell prompts. My prompts have been red ever since I nearly restarted MySQL on what I thought was a test machine. It takes two seconds.

Add Docker’s official APT repository

The Docker package in Ubuntu’s own repository can lag behind, depending on the release. On VPSs that will run for a long time, Docker’s official APT repository gives me a clearer update path. Install the helper packages first.

sudo apt update
sudo apt install -y ca-certificates curl

Store Docker’s signing key in a dedicated keyring directory. The older apt-key method still appears in many documents, but it is no longer the preferred approach.

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg 
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

Now create the repository entry using the system architecture and Ubuntu codename.

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu 
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | 
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

The signed-by setting matters because it restricts this key to this repository. Install Docker Engine and the Compose plugin next.

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Check the service and the installed version.

sudo systemctl enable --now docker
sudo systemctl is-active docker
docker version
sudo docker run --rm hello-world

is-active should return active. The hello-world image is downloaded, a short-lived container runs, and then exits. The first run needs access to Docker Hub, so DNS and outbound firewall problems often reveal themselves here.

Using Docker without sudo

Docker commands normally need root privileges after installation. Adding your account to the docker group is convenient.

sudo usermod -aG docker "$USER"
newgrp docker
docker ps

The -aG flags matter here. Using only -G can replace the user’s existing supplementary groups. newgrp docker activates the new membership in the current session; opening a new SSH session does the same thing.

There is a security cost. Membership in the docker group provides privileges very close to root. A member can mount the host filesystem into a container or start a privileged container, so I add only trusted administrators. Avoiding root for every task is sensible; giving every user access to Docker is just a cleaner-looking version of the same problem.

For a shared team server, evaluate rootless Docker separately. Rootless mode runs the daemon and containers as a non-root user, although some networking and storage features need extra configuration. On a simple single-user VPS, the classic setup may be easier to operate. On a multi-user system, decide on the security model before deploying workloads.

Run a small Nginx container

Now we can verify the installation with a real HTTP response. Port 8080 on the host will map to port 80 inside the container.

docker run -d 
  --name web-test 
  --restart unless-stopped 
  -p 8080:80 
  nginx:1.27-alpine

-d runs the container in the background. --name gives it a predictable name, and --restart unless-stopped brings it back after the Docker service restarts unless you explicitly stopped it. In -p 8080:80, the port on the left belongs to the VPS and the port on the right belongs inside the container.

I prefer an explicit image tag. latest is fine for a quick experiment, but later it makes it harder to identify exactly what was pulled. For production, use a specific version or a digest that has passed your tests.

Inspect the container and its port mapping.

docker ps
docker port web-test
curl -I http://127.0.0.1:8080

You should see a response containing something similar to HTTP/1.1 200 OK. From your own computer, test http://SERVER_IP:8080. If it fails, do not restart the container on instinct. Check docker ps, docker logs web-test, ss -lntp, and the firewall rules first.

The published port may not need to be public

The -p 8080:80 mapping listens on all interfaces by default. For a local test, bind it to loopback instead.

docker rm -f web-test
docker run -d 
  --name web-test 
  --restart unless-stopped 
  -p 127.0.0.1:8080:80 
  nginx:1.27-alpine

That prevents direct external access. Nginx on the host can then act as a reverse proxy and accept traffic on the public interface. If the final design includes a domain, TLS, and a reverse proxy, decide that traffic path before exposing a random high port to the internet.

If you use UFW, these commands open port 8080.

sudo ufw status verbose
sudo ufw allow 8080/tcp
sudo ufw reload

Remove the rule after testing. Docker’s firewall rules can also make UFW behave differently from what you expect, so inspect the firewall, SSH access, and published Docker ports together. The baseline checks in 10 Essential Steps to Secure and Harden Your Linux Server cover the host side.

Read logs before restarting anything

A running container does not prove that the application inside it is healthy. Logs are one of the first places I look.

docker logs --tail 100 web-test
docker inspect web-test
docker stats --no-stream web-test

docker logs displays the container’s standard output. inspect returns network settings, mounts, environment variables, and restart configuration as JSON. stats gives a one-time view of CPU, memory, and network usage.

I once ran a command from the wrong Compose project on what I believed was a test machine. The container names looked unfamiliar, so I stopped before running anything destructive. The server was correct; the project directory was not. Since then, I run both hostname and pwd before commands such as docker compose down. Look first.

Check the daemon’s logging driver.

docker info --format '{{.LoggingDriver}}'
cat /etc/docker/daemon.json 2>/dev/null || true

The default json-file driver stores container logs on the host. For long-running applications, configure rotation in /etc/docker/daemon.json.

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

Validate the file before restarting Docker.

python3 -m json.tool /etc/docker/daemon.json
sudo systemctl restart docker

These settings apply to newly created containers. Existing containers may need to be removed and recreated before they use the new logging options. A missing comma in this file can stop Docker from starting at all, so I do not skip the validation command.

Keep application data outside the container

The Nginx test container is stateless, so removing and recreating it is harmless. Databases, Nextcloud, Vaultwarden, and CMS installations are different. If important data exists only in a container’s writable layer, removing that container can remove your path back to it. Use a named volume or a controlled bind mount for data that must survive recreation.

docker volume create web-data
docker run -d 
  --name web-volume-test 
  --restart unless-stopped 
  -p 127.0.0.1:8081:80 
  -v web-data:/usr/share/nginx/html:ro 
  nginx:1.27-alpine

web-data is managed by Docker. To see its location and metadata:

docker volume inspect web-data

A host directory gives you more control over its location.

sudo install -d -m 0755 /srv/web/html
sudo docker run -d 
  --name web-bind-test 
  -p 127.0.0.1:8082:80 
  -v /srv/web/html:/usr/share/nginx/html:ro 
  nginx:1.27-alpine

The :ro suffix makes the mount read-only. That is useful when Nginx has no reason to write content. If permissions cause trouble, inspect the user inside the container and the owner of the host directory instead of reaching for chmod -R 777. That command changes the name of the problem more often than it solves it.

Backups need a plan too. Copying a database volume while the database is actively writing can produce an unusable backup. I use database dumps alongside rsync and borgbackup, then perform a restore test once a month. An untested backup is a large optimism file sitting on your server.

Use Compose when the command gets unwieldy

docker run is useful for learning and one-off tests. Once an application has several flags, mounts, networks, and environment variables, a Compose file is easier to review than a command copied from shell history. Check that the plugin is installed.

docker compose version

Create a project directory.

sudo install -d -m 0755 /opt/web-test
sudo chown "$USER":"$USER" /opt/web-test
cd /opt/web-test
vim compose.yaml

Put this in compose.yaml.

services:
  web:
    image: nginx:1.27-alpine
    container_name: web-compose
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro

Create a test page and start the service.

mkdir -p html
printf '%sn' '<h1>Docker VPS test succeeded</h1>' > html/index.html
docker compose up -d
docker compose ps
curl http://127.0.0.1:8080

The directory containing the Compose file matters. Run these commands elsewhere and Compose may not find the project you meant. I once ran docker compose down in the wrong directory on the correct server. Bringing the container back was easy; remembering which file belonged to which service was not.

Know what each lifecycle command does.

docker compose stop
docker compose start
docker compose down

stop stops the containers, while start starts them again. down removes the containers and network created by Compose; named volumes are not removed by default. Think twice before using down -v. A command that can delete production data is not automatically a suitable production command.

Check networking and host security together

Docker uses a bridge network by default. Services in the same Compose project can reach each other by service name, so an application can connect to a database at db:5432. If the database port is not published to the host, it has no reason to be reachable from the internet.

docker network ls
docker network inspect bridge
ss -lntp

In the ss output, look for only the listeners you expect. SSH may use port 22 and a reverse proxy may use 80 and 443, but a test port such as 8080 is easy to forget. Fail2ban and nftables can reduce the noise from SSH attacks. Changing the SSH port alone is not security.

Pull images from trusted sources, pin image tags, and avoid unnecessary container privileges. Do not use --privileged unless you understand exactly why it is required. When mounting host directories, provide only the path the application needs; mounting the entire / filesystem weakens the isolation you were trying to get from containers.

Do not update an image without a rollback path.

docker compose pull
docker compose up -d
docker image prune

docker image prune removes unused images and asks for confirmation by default. The old image used by a running container is not normally considered unused, but inspect what is present before pruning if you may need a quick rollback. I test a new tag on a staging VPS, check health and logs, and only then move it to production.

What I check after installation

Once Docker is installed, I run these checks in order. They separate “it seems to work” from a service I am willing to leave unattended.

  • Use systemctl is-active docker to verify the service.
  • Use docker ps to check expected containers and restart settings.
  • Read docker logs and look for application errors.
  • Inspect published ports with ss -lntp and review firewall rules.
  • Store persistent data in a volume or a controlled bind mount.
  • Send RAM, CPU, disk, and log metrics to your monitoring system.
  • Record image versions and update dates.
  • Perform a restore test that proves the backup can actually be used.

If the VPS has limited resources, measure memory limits and swap behavior before adding more containers. One container consuming the host can take down everything else on the machine. I push these limits in my Proxmox lab first, where an experiment can break without involving a customer server.

A first Nginx response from curl is only the first checkpoint. I consider the setup ready when I know the data survives recreation, the test port is private when it should be, and the logs cannot quietly fill the disk. That last check is the one I am least willing to skip now.

Avatar of Defne
Author

Defne