Running Docker locally is fine for development, but sooner or later you need a real remote host — something with a public IP, real compute, and enough resources to run more than a couple of containers at once. Azure is one of the three big options for that, and this guide walks through provisioning a Docker-ready VM on Azure from scratch, securing it properly, and connecting to its Docker daemon remotely.
Prerequisites
- An Azure account with an active subscription
- Azure CLI installed locally, or run it via container (see the companion guide on running cloud CLIs in Docker)
- SSH key pair for authentication (
ssh-keygen -t ed25519)
Step 1: Log In and Set Your Subscription
az login
az account list --output table
az account set --subscription "Pay-As-You-Go"
Step 2: Create a Resource Group
Resource groups are Azure’s logical container for related resources — keeping everything under one group makes cleanup trivial later.
az group create --name docker-rg --location eastus
Expected output:
{
"id": "/subscriptions/xxxx/resourceGroups/docker-rg",
"location": "eastus",
"name": "docker-rg",
"properties": {
"provisioningState": "Succeeded"
}
}
Step 3: Create a Virtual Machine
Azure has a marketplace image for Docker-ready hosts, but I generally prefer starting from plain Ubuntu and installing Docker myself via cloud-init — it keeps the setup transparent and reproducible.
az vm create \
--resource-group docker-rg \
--name docker-host-01 \
--image Ubuntu2404 \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_ed25519.pub \
--custom-data cloud-init-docker.yml
Where cloud-init-docker.yml looks like this:
#cloud-config
package_update: true
package_upgrade: true
runcmd:
- curl -fsSL https://get.docker.com -o get-docker.sh
- sh get-docker.sh
- usermod -aG docker azureuser
- systemctl enable docker
- systemctl start docker
Expected output after a minute or two:
{
"fqdns": "",
"location": "eastus",
"publicIpAddress": "20.185.44.112",
"resourceGroup": "docker-rg"
}
Step 4: Open the Required Network Ports
By default Azure VMs allow SSH (22) but nothing else. Open port 2376 if you plan to expose the Docker daemon remotely (with TLS — never without), and any application ports you need.
az vm open-port --resource-group docker-rg --name docker-host-01 --port 2376 --priority 900
az vm open-port --resource-group docker-rg --name docker-host-01 --port 80 --priority 910
Step 5: SSH In and Verify Docker
ssh azureuser@20.185.44.112
docker --version
docker run hello-world
Expected output:
Docker version 27.3.1, build ce12230
...
Hello from Docker!
This message shows that your installation appears to be working correctly.
Step 6: Securing Remote Docker API Access (Optional but Important)
If you want to run docker commands against this host from your laptop (rather than SSHing in every time), you need to expose the Docker daemon over TCP — and that must be done with TLS certificates, never in plaintext, since an open Docker socket is equivalent to root access on the host.
On the VM, generate a CA and server certificates:
mkdir -p ~/docker-certs && cd ~/docker-certs
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=$(hostname)" -sha256 -new -key server-key.pem -out server.csr
echo subjectAltName = IP:20.185.44.112,IP:127.0.0.1 > extfile.cnf
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out server-cert.pem -extfile extfile.cnf
Then configure the Docker daemon to use TLS by editing /etc/docker/daemon.json:
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/ca.pem",
"tlscert": "/etc/docker/server-cert.pem",
"tlskey": "/etc/docker/server-key.pem"
}
Restart Docker:
sudo systemctl restart docker
On your laptop, generate a client cert signed by the same CA and connect:
export DOCKER_HOST=tcp://20.185.44.112:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=~/docker-certs/client
docker version
Step 7: Attach a Managed Disk for Persistent Storage
If your containers need persistent storage beyond the OS disk, attach and mount an Azure managed disk.
az disk create \
--resource-group docker-rg \
--name docker-data-disk \
--size-gb 64 \
--sku Premium_LRS
az vm disk attach \
--resource-group docker-rg \
--vm-name docker-host-01 \
--name docker-data-disk
On the VM:
sudo lsblk
sudo mkfs.ext4 /dev/sdc
sudo mkdir /mnt/docker-data
sudo mount /dev/sdc /mnt/docker-data
Then point Docker’s data root at this disk by adding "data-root": "/mnt/docker-data/docker" to /etc/docker/daemon.json and restarting Docker.
Networking Fundamentals on This Host
Once Docker is running on the Azure VM, container networking behaves exactly as it does anywhere else — the docker0 bridge handles container-to-container traffic on the host, and published ports (-p 80:80) are what Azure’s Network Security Group rules need to allow through externally. It’s a common point of confusion: opening a port in the NSG doesn’t automatically mean a container is listening on it — you still need -p (or a Compose ports: entry) to actually bind the container’s port to the host.
Running a Real Workload
docker run -d --name nginx-demo -p 80:80 --restart unless-stopped nginx:latest
Visit http://20.185.44.112 in a browser — you should see the default Nginx welcome page.
Monitoring the Host
docker stats --no-stream
Expected output:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O
a1b2c3d4e5f6 nginx-demo 0.00% 2.4MiB / 1.91GiB 0.12% 1.2kB / 0B
For anything beyond ad-hoc checks, install cadvisor or point Azure Monitor’s VM insights at the host for CPU, memory, and disk metrics over time.
Deploying with Docker Compose on the Azure VM
Most real workloads involve more than a single container, and Compose is usually the fastest way to define them. Install the Compose plugin (already included if you used the get-docker.sh convenience script above) and define a stack:
# docker-compose.yml
services:
web:
image: nginx:latest
ports:
- "80:80"
restart: unless-stopped
depends_on:
- api
api:
image: myregistry.example.com/api:1.0.0
environment:
- DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
restart: unless-stopped
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=appdb
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=apppass
restart: unless-stopped
volumes:
db-data:
docker compose up -d
Expected output:
[+] Running 4/4
✔ Network azureuser_default Created
✔ Container azureuser-db-1 Started
✔ Container azureuser-api-1 Started
✔ Container azureuser-web-1 Started
Using an Azure Load Balancer for High Availability
A single VM is a single point of failure. For anything beyond a dev/test environment, put an Azure Load Balancer or Application Gateway in front of two or more identically configured Docker hosts, using a Virtual Machine Scale Set (VMSS) so Azure can add or remove instances automatically based on load:
az vmss create \
--resource-group docker-rg \
--name docker-vmss \
--image Ubuntu2404 \
--custom-data cloud-init-docker.yml \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_ed25519.pub \
--instance-count 2 \
--lb-sku Standard \
--public-ip-per-vm false
Each instance in the scale set boots with the same cloud-init script, so Docker and your container startup logic come up identically on every node.
Understanding Azure’s Networking Layers
It helps to keep Azure’s network layers distinct in your head, since they each control a different thing:
- Virtual Network (VNet) and Subnets — define the private IP address space the VM lives in.
- Network Security Group (NSG) — the stateful firewall controlling inbound/outbound traffic to the VM’s network interface; this is what
az vm open-portmodifies. - Docker’s own bridge network — entirely internal to the VM, invisible to Azure; controls how containers talk to each other and to the host.
- Published ports (
-p) — the bridge between the two; a container port isn’t reachable from the VNet or the internet until it’s published to the host, and the host port isn’t reachable externally until the NSG allows it.
Missing any one of these three layers is the most common reason a “working” container is unreachable from outside the VM.
Best Practices
- Never expose the Docker API without TLS. An unauthenticated Docker socket over the network is a full remote-root vulnerability.
- Use Azure Network Security Groups as a second layer on top of TLS — restrict port 2376 to known IP ranges rather than
0.0.0.0/0. - Enable automatic OS patching via Azure’s VM guest patching settings, or handle it yourself with unattended-upgrades.
- Use a managed disk for
/var/lib/dockerif you expect meaningful image/container storage growth — resizing the OS disk later is more disruptive. - Tag resources (
--tags env=prod team=platform) from day one; cleanup and cost attribution get painful otherwise. - Consider Azure Container Instances or AKS instead of a raw VM if you don’t specifically need daemon-level control — a VM is the right choice when you need custom daemon config, privileged containers, or GPU passthrough.
Troubleshooting
- VM creation fails with quota errors — check
az vm list-usage --location eastus --output tablefor your subscription’s vCPU quota. - Can’t SSH in — confirm the NSG allows port 22 from your IP, and that you’re using the correct username (
azureuserby default in the example above). docker run hello-worldhangs — checksystemctl status dockeron the VM; cloud-init logs at/var/log/cloud-init-output.logwill show if the install script failed.- Remote TLS connection refused from laptop — confirm port 2376 is open in the NSG and that
daemon.jsonwas loaded correctly (sudo systemctl status dockerwill show a parse error if the JSON is malformed).
Summary
Standing up a Docker host on Azure is a matter of provisioning a VM (via cloud-init for a repeatable setup), opening the right ports, and — if remote access is needed — securing the Docker API with TLS certificates rather than leaving it open. From there, container networking, storage, and monitoring on Azure behave exactly like they would on any Linux Docker host; the cloud-specific work is really just the provisioning and network security layer around it.
References
- Azure CLI documentation: https://learn.microsoft.com/en-us/cli/azure/
- Docker Engine installation guide: https://docs.docker.com/engine/install/ubuntu/
- Docker Engine API security (TLS): https://docs.docker.com/engine/security/protect-access/
- Azure Virtual Machines documentation: https://learn.microsoft.com/en-us/azure/virtual-machines/