{"id":304,"date":"2026-08-29T09:03:50","date_gmt":"2026-08-29T09:03:50","guid":{"rendered":"https:\/\/www.vps.tc\/blog\/?p=304"},"modified":"2026-08-29T09:03:50","modified_gmt":"2026-08-29T09:03:50","slug":"docker-installation-on-vps-first-container","status":"publish","type":"post","link":"https:\/\/www.vps.tc\/blog\/en\/docker-installation-on-vps-first-container\/","title":{"rendered":"How to Install Docker on a VPS and Run Your First Container"},"content":{"rendered":"<h2>Docker on a VPS: the installation is the easy part<\/h2>\n<p>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.<\/p>\n<p>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.<\/p>\n<p>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.<\/p>\n<pre><code>cat \/etc\/os-release\nuname -a\nnproc\nfree -h\ndf -h<\/code><\/pre>\n<p>An empty swap configuration shown by <code>free -h<\/code> 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 <a>Launch a Secure VPS in 30 Minutes | Pro Admin Guide<\/a> are useful for that first pass.<\/p>\n<h2>Check for an older installation first<\/h2>\n<p>Ubuntu&#8217;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.<\/p>\n<pre><code>docker --version 2&gt;\/dev\/null || true\ndpkg -l | grep -E 'docker|containerd' || true\nsystemctl status docker --no-pager 2&gt;\/dev\/null || true<\/code><\/pre>\n<p>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&#8217;s data directories, but cleanup without a rollback plan is still bad administration.<\/p>\n<pre><code>sudo apt remove -y docker.io docker-doc docker-compose podman-docker containerd runc<\/code><\/pre>\n<p>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 <code>hostname<\/code> showed that it was a helper server close to production. It was not the staging box in my head.<\/p>\n<p>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.<\/p>\n<h2>Add Docker&#8217;s official APT repository<\/h2>\n<p>The Docker package in Ubuntu&#8217;s own repository can lag behind, depending on the release. On VPSs that will run for a long time, Docker&#8217;s official APT repository gives me a clearer update path. Install the helper packages first.<\/p>\n<pre><code>sudo apt update\nsudo apt install -y ca-certificates curl<\/code><\/pre>\n<p>Store Docker&#8217;s signing key in a dedicated keyring directory. The older <code>apt-key<\/code> method still appears in many documents, but it is no longer the preferred approach.<\/p>\n<pre><code>sudo install -m 0755 -d \/etc\/apt\/keyrings\nsudo curl -fsSL https:\/\/download.docker.com\/linux\/ubuntu\/gpg \n  -o \/etc\/apt\/keyrings\/docker.asc\nsudo chmod a+r \/etc\/apt\/keyrings\/docker.asc<\/code><\/pre>\n<p>Now create the repository entry using the system architecture and Ubuntu codename.<\/p>\n<pre><code>echo \"deb [arch=$(dpkg --print-architecture) signed-by=\/etc\/apt\/keyrings\/docker.asc] https:\/\/download.docker.com\/linux\/ubuntu \n$(. \/etc\/os-release &amp;&amp; echo \"$VERSION_CODENAME\") stable\" | \n  sudo tee \/etc\/apt\/sources.list.d\/docker.list &gt; \/dev\/null<\/code><\/pre>\n<p>The <code>signed-by<\/code> setting matters because it restricts this key to this repository. Install Docker Engine and the Compose plugin next.<\/p>\n<pre><code>sudo apt update\nsudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin<\/code><\/pre>\n<p>Check the service and the installed version.<\/p>\n<pre><code>sudo systemctl enable --now docker\nsudo systemctl is-active docker\ndocker version\nsudo docker run --rm hello-world<\/code><\/pre>\n<p><code>is-active<\/code> should return <code>active<\/code>. The <code>hello-world<\/code> 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.<\/p>\n<h2>Using Docker without <code>sudo<\/code><\/h2>\n<p>Docker commands normally need root privileges after installation. Adding your account to the <code>docker<\/code> group is convenient.<\/p>\n<pre><code>sudo usermod -aG docker \"$USER\"\nnewgrp docker\ndocker ps<\/code><\/pre>\n<p>The <code>-aG<\/code> flags matter here. Using only <code>-G<\/code> can replace the user&#8217;s existing supplementary groups. <code>newgrp docker<\/code> activates the new membership in the current session; opening a new SSH session does the same thing.<\/p>\n<p>There is a security cost. Membership in the <code>docker<\/code> 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.<\/p>\n<p>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.<\/p>\n<h2>Run a small Nginx container<\/h2>\n<p>Now we can verify the installation with a real HTTP response. Port 8080 on the host will map to port 80 inside the container.<\/p>\n<pre><code>docker run -d \n  --name web-test \n  --restart unless-stopped \n  -p 8080:80 \n  nginx:1.27-alpine<\/code><\/pre>\n<p><code>-d<\/code> runs the container in the background. <code>--name<\/code> gives it a predictable name, and <code>--restart unless-stopped<\/code> brings it back after the Docker service restarts unless you explicitly stopped it. In <code>-p 8080:80<\/code>, the port on the left belongs to the VPS and the port on the right belongs inside the container.<\/p>\n<p>I prefer an explicit image tag. <code>latest<\/code> 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.<\/p>\n<p>Inspect the container and its port mapping.<\/p>\n<pre><code>docker ps\ndocker port web-test\ncurl -I http:\/\/127.0.0.1:8080<\/code><\/pre>\n<p>You should see a response containing something similar to <code>HTTP\/1.1 200 OK<\/code>. From your own computer, test <code>http:\/\/SERVER_IP:8080<\/code>. If it fails, do not restart the container on instinct. Check <code>docker ps<\/code>, <code>docker logs web-test<\/code>, <code>ss -lntp<\/code>, and the firewall rules first.<\/p>\n<h3>The published port may not need to be public<\/h3>\n<p>The <code>-p 8080:80<\/code> mapping listens on all interfaces by default. For a local test, bind it to loopback instead.<\/p>\n<pre><code>docker rm -f web-test\ndocker run -d \n  --name web-test \n  --restart unless-stopped \n  -p 127.0.0.1:8080:80 \n  nginx:1.27-alpine<\/code><\/pre>\n<p>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.<\/p>\n<p>If you use UFW, these commands open port 8080.<\/p>\n<pre><code>sudo ufw status verbose\nsudo ufw allow 8080\/tcp\nsudo ufw reload<\/code><\/pre>\n<p>Remove the rule after testing. Docker&#8217;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 <a>10 Essential Steps to Secure and Harden Your Linux Server<\/a> cover the host side.<\/p>\n<h2>Read logs before restarting anything<\/h2>\n<p>A running container does not prove that the application inside it is healthy. Logs are one of the first places I look.<\/p>\n<pre><code>docker logs --tail 100 web-test\ndocker inspect web-test\ndocker stats --no-stream web-test<\/code><\/pre>\n<p><code>docker logs<\/code> displays the container&#8217;s standard output. <code>inspect<\/code> returns network settings, mounts, environment variables, and restart configuration as JSON. <code>stats<\/code> gives a one-time view of CPU, memory, and network usage.<\/p>\n<p>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 <code>hostname<\/code> and <code>pwd<\/code> before commands such as <code>docker compose down<\/code>. Look first.<\/p>\n<p>Check the daemon&#8217;s logging driver.<\/p>\n<pre><code>docker info --format '{{.LoggingDriver}}'\ncat \/etc\/docker\/daemon.json 2&gt;\/dev\/null || true<\/code><\/pre>\n<p>The default <code>json-file<\/code> driver stores container logs on the host. For long-running applications, configure rotation in <code>\/etc\/docker\/daemon.json<\/code>.<\/p>\n<pre><code>{\n  \"log-driver\": \"json-file\",\n  \"log-opts\": {\n    \"max-size\": \"10m\",\n    \"max-file\": \"3\"\n  }\n}<\/code><\/pre>\n<p>Validate the file before restarting Docker.<\/p>\n<pre><code>python3 -m json.tool \/etc\/docker\/daemon.json\nsudo systemctl restart docker<\/code><\/pre>\n<p>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.<\/p>\n<h2>Keep application data outside the container<\/h2>\n<p>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&#8217;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.<\/p>\n<pre><code>docker volume create web-data\ndocker run -d \n  --name web-volume-test \n  --restart unless-stopped \n  -p 127.0.0.1:8081:80 \n  -v web-data:\/usr\/share\/nginx\/html:ro \n  nginx:1.27-alpine<\/code><\/pre>\n<p><code>web-data<\/code> is managed by Docker. To see its location and metadata:<\/p>\n<pre><code>docker volume inspect web-data<\/code><\/pre>\n<p>A host directory gives you more control over its location.<\/p>\n<pre><code>sudo install -d -m 0755 \/srv\/web\/html\nsudo docker run -d \n  --name web-bind-test \n  -p 127.0.0.1:8082:80 \n  -v \/srv\/web\/html:\/usr\/share\/nginx\/html:ro \n  nginx:1.27-alpine<\/code><\/pre>\n<p>The <code>:ro<\/code> 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 <code>chmod -R 777<\/code>. That command changes the name of the problem more often than it solves it.<\/p>\n<p>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 <code>rsync<\/code> and <code>borgbackup<\/code>, then perform a restore test once a month. An untested backup is a large optimism file sitting on your server.<\/p>\n<h2>Use Compose when the command gets unwieldy<\/h2>\n<p><code>docker run<\/code> 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.<\/p>\n<pre><code>docker compose version<\/code><\/pre>\n<p>Create a project directory.<\/p>\n<pre><code>sudo install -d -m 0755 \/opt\/web-test\nsudo chown \"$USER\":\"$USER\" \/opt\/web-test\ncd \/opt\/web-test\nvim compose.yaml<\/code><\/pre>\n<p>Put this in <code>compose.yaml<\/code>.<\/p>\n<pre><code>services:\n  web:\n    image: nginx:1.27-alpine\n    container_name: web-compose\n    restart: unless-stopped\n    ports:\n      - \"127.0.0.1:8080:80\"\n    volumes:\n      - .\/html:\/usr\/share\/nginx\/html:ro<\/code><\/pre>\n<p>Create a test page and start the service.<\/p>\n<pre><code>mkdir -p html\nprintf '%sn' '&lt;h1&gt;Docker VPS test succeeded&lt;\/h1&gt;' &gt; html\/index.html\ndocker compose up -d\ndocker compose ps\ncurl http:\/\/127.0.0.1:8080<\/code><\/pre>\n<p>The directory containing the Compose file matters. Run these commands elsewhere and Compose may not find the project you meant. I once ran <code>docker compose down<\/code> in the wrong directory on the correct server. Bringing the container back was easy; remembering which file belonged to which service was not.<\/p>\n<p>Know what each lifecycle command does.<\/p>\n<pre><code>docker compose stop\ndocker compose start\ndocker compose down<\/code><\/pre>\n<p><code>stop<\/code> stops the containers, while <code>start<\/code> starts them again. <code>down<\/code> removes the containers and network created by Compose; named volumes are not removed by default. Think twice before using <code>down -v<\/code>. A command that can delete production data is not automatically a suitable production command.<\/p>\n<h2>Check networking and host security together<\/h2>\n<p>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 <code>db:5432<\/code>. If the database port is not published to the host, it has no reason to be reachable from the internet.<\/p>\n<pre><code>docker network ls\ndocker network inspect bridge\nss -lntp<\/code><\/pre>\n<p>In the <code>ss<\/code> 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.<\/p>\n<p>Pull images from trusted sources, pin image tags, and avoid unnecessary container privileges. Do not use <code>--privileged<\/code> unless you understand exactly why it is required. When mounting host directories, provide only the path the application needs; mounting the entire <code>\/<\/code> filesystem weakens the isolation you were trying to get from containers.<\/p>\n<p>Do not update an image without a rollback path.<\/p>\n<pre><code>docker compose pull\ndocker compose up -d\ndocker image prune<\/code><\/pre>\n<p><code>docker image prune<\/code> 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.<\/p>\n<h2>What I check after installation<\/h2>\n<p>Once Docker is installed, I run these checks in order. They separate &#8220;it seems to work&#8221; from a service I am willing to leave unattended.<\/p>\n<ul>\n<li>Use <code>systemctl is-active docker<\/code> to verify the service.<\/li>\n<li>Use <code>docker ps<\/code> to check expected containers and restart settings.<\/li>\n<li>Read <code>docker logs<\/code> and look for application errors.<\/li>\n<li>Inspect published ports with <code>ss -lntp<\/code> and review firewall rules.<\/li>\n<li>Store persistent data in a volume or a controlled bind mount.<\/li>\n<li>Send RAM, CPU, disk, and log metrics to your monitoring system.<\/li>\n<li>Record image versions and update dates.<\/li>\n<li>Perform a restore test that proves the backup can actually be used.<\/li>\n<\/ul>\n<p>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.<\/p>\n<p>A first Nginx response from <code>curl<\/code> 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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Install Docker Engine on an Ubuntu VPS, run an Nginx container, and learn how to handle ports, permissions, logs, persistent data, Compose, and security.<\/p>\n","protected":false},"author":2,"featured_media":302,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[9],"tags":[1255,1264,1258,441,41,1261,29],"class_list":["post-304","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to","tag-docker-en","tag-docker-compose-en","tag-docker-installation-on-vps","tag-linux-server-security","tag-nginx","tag-ubuntu-en","tag-vps"],"lang":"en","translations":{"en":304,"tr":303},"pll_sync_post":[],"_links":{"self":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/304","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/comments?post=304"}],"version-history":[{"count":1,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/304\/revisions"}],"predecessor-version":[{"id":305,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/304\/revisions\/305"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media\/302"}],"wp:attachment":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media?parent=304"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/categories?post=304"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/tags?post=304"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}