DEV Community

Cover image for From “It Works on My Machine” to Azure: Deploying a Dockerized Notes App in 3 Ways, Breaking Things, Fixing Them, and Learning Along the Way
David Cletus
David Cletus

Posted on

From “It Works on My Machine” to Azure: Deploying a Dockerized Notes App in 3 Ways, Breaking Things, Fixing Them, and Learning Along the Way

"It works on my machine" is a fine place to start. It's a terrible place to stop.

I built a small Notes app with Node.js, Express and PostgreSQL, put it in Docker, and then deployed that same container image in three different ways:

  1. Localhost with Docker Compose
  2. Azure App Service for Containers with Azure Database for PostgreSQL (managed PaaS)
  3. An Azure Virtual Machine running Docker Compose (IaaS)

I also did a quick detour through Azure Container Instances (ACI) as a warm-up, because it taught me something useful.

Nothing went perfectly on the first try, and I left the errors in this post. They taught me more than the parts that worked.

Source code: GitHub repo
Docker image: 4thman/notes-app on Docker Hub


What we're building

A Notes app where you can add a note (title and content), see all notes sorted newest first, and delete notes. The notes live in PostgreSQL, so they need to survive restarts.

Layer Technology
Frontend Plain HTML/CSS/JS served as static files
Backend Node.js 20 + Express
Database PostgreSQL 16
Packaging Docker + Docker Compose
Registry Docker Hub
Cloud Azure (ACI, App Service, PostgreSQL Flexible Server, VM)

The three deployments at a glance

Localhost App Service + managed Postgres VM + Compose
Who manages the OS? You Azure You
Who manages the database? A container Azure A container on the VM
Public URL localhost:3000 *.azurewebsites.net (HTTPS) VM-IP:3000
Effort Low Medium (lots of settings) Medium (lots of setup)
Best for Development Production-style apps Full control, learning

What you need

  • Docker Desktop (with WSL on Windows)
  • Node.js and npm
  • VS Code with Git Bash
  • Azure CLI and an Azure subscription
  • A Docker Hub account
  • A GitHub account

Part 1: Build the app and run it locally

Project structure

I created the project folders from the terminal:

mkdir dockerapp-project
cd dockerapp-project
mkdir david-note
cd david-note
mkdir public
touch public/index.html Dockerfile server.js docker-compose.yaml
Enter fullscreen mode Exit fullscreen mode

Creating the project folder and opening it in VS Code:

Creating the david-note and public folders and the index.html file:

Creating the Dockerfile and server.js:

You end up with this:

david-note/
├── public/
│   └── index.html
├── server.js
├── Dockerfile
└── docker-compose.yaml
Enter fullscreen mode Exit fullscreen mode

The frontend

public/index.html is a single page with a form (title, content, an "Add Note" button) and a list of notes, each with a Delete link. It calls the backend API with fetch.

The backend (server.js)

The server does four things:

  • Connects to PostgreSQL using environment variables
  • Creates the notes table on startup if it doesn't exist
  • Exposes a small REST API
  • Serves the static frontend

The connection pool is the part that matters for deployment:

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'db',
  port: Number(process.env.DB_PORT) || 5432,
  user: process.env.DB_USER || 'appuser',
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME || 'notesdb',
  ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
});
Enter fullscreen mode Exit fullscreen mode

Everything is configurable through environment variables. The same image can talk to a Postgres container on my laptop, a Postgres container on a VM, or Azure's managed Postgres, and I never rebuild it. The DB_SSL flag matters later, because Azure's managed Postgres requires encrypted connections.

On startup the app creates the table:

async function initDb() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS notes (
      id SERIAL PRIMARY KEY,
      title VARCHAR(255) NOT NULL,
      content TEXT,
      created_at TIMESTAMP DEFAULT NOW()
    );
  `);
}
Enter fullscreen mode Exit fullscreen mode

The API has three routes:

  • GET /api/notes lists notes, newest first
  • POST /api/notes creates a note
  • DELETE /api/notes/:id deletes a note

There's also a health route:

app.get('/health', (req, res) => res.json({ status: 'ok' }));
Enter fullscreen mode Exit fullscreen mode

I made a deliberate decision here. If the database connection fails at startup, the server logs the error and keeps running instead of crashing. A crash loop gives you nothing to debug. A running server that logs "Database initialization failed" and still answers /health tells you exactly where the problem is. That choice paid off during the Azure deployment.

server.js, part 1 (imports, connection pool, table creation, GET route):

server.js, part 2 (POST, DELETE, health check and startup):

The Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

The order matters. Copying package*.json first and running npm install before copying the rest of the code means Docker caches the dependency layer. If you only change server.js, rebuilds take seconds instead of reinstalling everything.

The Compose file

services:
  app:
    build: .
    image: 4thman/notes-app:V1
    ports:
      - "3000:3000"
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: appuser
      DB_PASSWORD: <your-local-password>
      DB_NAME: notesdb
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: <your-local-password>
      POSTGRES_DB: notesdb
    volumes:
      - db_data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  db_data:
Enter fullscreen mode Exit fullscreen mode

Three details worth pointing out:

  • build: . and image: together. Compose builds the image from the local Dockerfile and tags it 4thman/notes-app:V1. That tag is what I push to Docker Hub later, so the image I tested locally is the image I deploy.
  • DB_HOST: db. Inside the Compose network, containers reach each other by service name.
  • The db_data volume. This is what keeps notes alive when containers are removed.

Creating the Compose file:

The Compose script in the editor:

Error #1: Cannot find module 'pg'

I ran npm init -y and npm install express to generate package.json:

Then I built and started everything:

docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

The build succeeded and the containers started. But the app wasn't reachable. docker compose ps showed the app container as Restarting:

docker compose ps
docker compose logs app
Enter fullscreen mode Exit fullscreen mode

The logs gave the reason:

Error: Cannot find module 'pg'
Require stack:
- /app/server.js
Enter fullscreen mode Exit fullscreen mode

Cause: server.js requires the pg PostgreSQL driver, but I had only installed express. package.json didn't list pg, so RUN npm install inside the image never installed it.

Fix:

npm install express pg
docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

The --build flag forces a rebuild so the image picks up the updated package.json. This time the app came up.

Lesson: the container only knows what's in package.json. If it works on your machine because of a global install, it will break in Docker. Also, docker compose logs <service> should be the first thing you reach for when a container keeps restarting.

Proving the data survives

At http://localhost:3000 the app loaded:

I added a note ("Hello" / "This is David test running the notes-app"), then typed a second one:

Then I tore everything down:

docker compose down
Enter fullscreen mode Exit fullscreen mode

localhost:3000 now showed ERR_CONNECTION_REFUSED:

Then I brought it back:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Both notes were still there:

docker compose down removes containers and the network, but not named volumes, so Postgres picked up its data again. If I had run docker compose down -v, the notes would have been wiped.

Quick note: Compose printed a warning that the version attribute is obsolete. It's harmless, since modern Compose ignores it, but you can delete that line.


Part 2: Push the image to Docker Hub

Every cloud deployment below pulls the image from a registry, so this step connects local to cloud.

First, a .dockerignore so junk doesn't end up inside the image:

node_modules
.git
.env
Enter fullscreen mode Exit fullscreen mode

Without it, COPY . . would copy your local node_modules into the image and overwrite the clean ones that npm install just built. It also keeps .env secrets out.

Then I logged in and confirmed the image existed:

docker compose down
docker login
docker image ls
Enter fullscreen mode Exit fullscreen mode

And pushed it:

docker push 4thman/notes-app:V1
Enter fullscreen mode Exit fullscreen mode

You might notice the push output says "Mounted from 4thman/hagital" for several layers. Those layers (the Node base image) were already in my account from a previous project, so Docker Hub didn't need them uploaded again. Only the new layers were pushed.

The repository now shows up at hub.docker.com/r/4thman/notes-app with the V1 tag.


Part 3 (the warm-up): Azure Container Instances

Before App Service, I tried the simplest Azure option: ACI runs containers with no VM or orchestrator to manage.

Register the resource providers

On a fresh subscription, services often fail until their resource provider is registered. I registered the ones I'd need up front:

az provider register --namespace Microsoft.ContainerInstance
az provider register --namespace Microsoft.DBforPostgreSQL
az provider register --namespace Microsoft.Web

az provider show --namespace Microsoft.Web --query registrationState --output tsv
Enter fullscreen mode Exit fullscreen mode

The last command printed Registering and later Registered. Registration takes a minute or two, so check the state before moving on.

Variables and a resource group

To avoid retyping, I set shell variables:

DOCKER_USER="4thman"
SUFFIX="<your-unique-suffix>"
LOCATION="eastus"
DB_PASS="<a-strong-password>"
Enter fullscreen mode Exit fullscreen mode

Checking that they were set:

echo $DOCKER_USER $SUFFIX $LOCATION
Enter fullscreen mode Exit fullscreen mode

Then the resource group:

az group create --name notes-app-rg --location $LOCATION
Enter fullscreen mode Exit fullscreen mode

provisioningState: Succeeded in the output confirmed the group existed.

The YAML deployment file

ACI can run several containers in one container group, and containers in a group share a network. That means the app can reach Postgres on localhost. I described the group in aci-notes.yaml:


apiVersion: 2021-10-01
location: eastus
name: notes-app-group
properties:
  containers:
    - name: app
      properties:
        image: 4thman/notes-app:V1
        command: ["/bin/sh", "-c", "sleep 20 && node server.js"]
        environmentVariables:
          - name: DB_HOST
            value: localhost
          - name: DB_PORT
            value: "5432"
          - name: DB_USER
            value: appuser
          - name: DB_PASSWORD
            secureValue: "<your-password>"
          - name: DB_NAME
            value: notesdb
        ports:
          - port: 3000
        resources:
          requests:
            cpu: 1.0
            memoryInGb: 1.0
    - name: db
      properties:
        image: postgres:16-alpine
        environmentVariables:
          - name: POSTGRES_USER
            value: appuser
          - name: POSTGRES_PASSWORD
            secureValue: "<your-password>"
          - name: POSTGRES_DB
            value: notesdb
        ports:
          - port: 5432
        resources:
          requests:
            cpu: 1.0
            memoryInGb: 1.0
  osType: Linux
  ipAddress:
    type: Public
    dnsNameLabel: notes-app-<your-suffix>
    ports:
      - protocol: tcp
        port: 3000
  restartPolicy: Always
type: Microsoft.ContainerInstance/containerGroups
Enter fullscreen mode Exit fullscreen mode

The first half of the file (the app and db containers):

The second half (public IP, DNS label, port and restart policy):

Two things I want to explain:

  • sleep 20 && node server.js. ACI has no equivalent of Compose's depends_on. Both containers start at the same time, and Postgres needs a few seconds before it accepts connections. The delay gives the database time to be ready before the app tries to create its table.
  • secureValue instead of value for passwords, so they aren't shown in the container group's properties.

Deploy it:

az container create --resource-group notes-app-rg --file aci-notes.yaml
Enter fullscreen mode Exit fullscreen mode

provisioningState: Succeeded came back:

Then I grabbed the address:

az container show \
  --resource-group notes-app-rg \
  --name notes-app-group \
  --query ipAddress.fqdn --output tsv
Enter fullscreen mode Exit fullscreen mode

And checked the logs:

az container logs \
  --resource-group notes-app-rg \
  --name notes-app-group \
  --container-name app
Enter fullscreen mode Exit fullscreen mode

The logs showed exactly what I wanted:

Notes app running on port 3000
Database initialized (notes table ready)
Enter fullscreen mode Exit fullscreen mode

I opened http://notes-app-<suffix>.eastus.azurecontainer.io:3000:

Then I added a few notes to test it, and they were saved:

Why I moved on

ACI is great for quick demos, but this setup has no volume for Postgres. If the group restarts, the database starts empty. Also, a database in a container is the wrong model for anything you care about. So I deleted it:

az container delete --resource-group notes-app-rg --name notes-app-group --yes
Enter fullscreen mode Exit fullscreen mode

Reloading the URL now gave DNS_PROBE_FINISHED_NXDOMAIN, which confirms the DNS name went away with the container:

Then I removed the whole resource group and created a fresh one for the next attempt:

az group delete --name notes-app-rg --yes --no-wait
az group create --name notes-prod-rg --location $LOCATION
Enter fullscreen mode Exit fullscreen mode


Part 4: Azure App Service + Azure Database for PostgreSQL

This is the "proper" cloud setup. Azure runs the database and the web hosting, and I only supply the image and the configuration.

Step 1: Create the managed PostgreSQL server

az postgres flexible-server create \
  --resource-group notes-prod-rg \
  --name notes-db-$SUFFIX \
  --location eastus2 \
  --admin-user appuser \
  --admin-password "$DB_PASS" \
  --sku-name Standard_B1ms \
  --tier Burstable \
  --version 16 \
  --public-access 0.0.0.0 \
  --yes
Enter fullscreen mode Exit fullscreen mode

What those flags mean:

  • Standard_B1ms and Burstable is the cheap tier, fine for a demo.
  • --public-access 0.0.0.0 creates a firewall rule that allows connections from other Azure services. That's how App Service will reach the database.
  • --version 16 matches the Postgres version I used locally.

The CLI also warns that the output includes your password in plain text. Treat that output as sensitive and never paste it into a blog post.

Then I created the application database:

az postgres flexible-server db create \
  --resource-group notes-prod-rg \
  --server-name notes-db-$SUFFIX \
  --name notesdb
Enter fullscreen mode Exit fullscreen mode

And captured the server address:

DB_HOST=$(az postgres flexible-server show \
  --resource-group notes-prod-rg \
  --name notes-db-$SUFFIX \
  --query fullyQualifiedDomainName --output tsv)

echo $DB_HOST
Enter fullscreen mode Exit fullscreen mode

The server showed state Ready with a host like notes-db-<suffix>.postgres.database.azure.com.

Step 2: Create the App Service plan

Error #2: free-tier quota in East US

az appservice plan create \
  --name notes-app-plan \
  --resource-group notes-prod-rg \
  --is-linux --sku F1 --location eastus
Enter fullscreen mode Exit fullscreen mode

This failed:

Current Limit (F1 VMs): 0
Amount required for this deployment (F1 VMs): 1
Enter fullscreen mode Exit fullscreen mode

Cause: my subscription had zero free-tier (F1) capacity in East US.

Fix: I tried another region instead of fighting for a quota increase:

az appservice plan create \
  --name notes-app-plan \
  --resource-group notes-prod-rg \
  --is-linux --sku F1 --location centralus
Enter fullscreen mode Exit fullscreen mode

That worked (provisioningState: Succeeded). The trade-off is that my app (Central US) and database (East US 2) are now in different regions, which adds a little latency. For production, you'd want them in the same region.

Step 3: Create the web app from the Docker Hub image

az webapp create \
  --resource-group notes-prod-rg \
  --plan notes-app-plan \
  --name notes-app-$SUFFIX \
  --deployment-container-image-name 4thman/notes-app:V1
Enter fullscreen mode Exit fullscreen mode

The CLI warned me that --deployment-container-image-name is deprecated and will be removed in a future release, so check az webapp create --help for the current flag name.

Step 4: Configure the app

The container reads its database settings from environment variables, so I set them as App Service application settings:

az webapp config appsettings set \
  --resource-group notes-prod-rg \
  --name notes-app-$SUFFIX \
  --settings \
    DB_HOST="$DB_HOST" \
    DB_PORT="5432" \
    DB_USER="appuser" \
    DB_PASSWORD="$DB_PASS" \
    DB_NAME="notesdb" \
    DB_SSL="true" \
    PGSSLMODE="require" \
    WEBSITES_PORT="3000"
Enter fullscreen mode Exit fullscreen mode

Three of these settings matter more than they look:

  • DB_SSL=true turns on the SSL option in my pg pool. Azure's managed Postgres rejects unencrypted connections.
  • WEBSITES_PORT=3000 tells App Service which port my container listens on. Without it, App Service assumes port 80 and can't find the app.
  • DB_HOST is now the managed server's full address instead of db.

)

Then I restarted the app:

az webapp restart --resource-group notes-prod-rg --name notes-app-$SUFFIX
Enter fullscreen mode Exit fullscreen mode

And printed the site URL to open it:

Error #3: 503 Service Unavailable

I opened https://notes-app-<suffix>.azurewebsites.net and got:

503 Service Unavailable
Enter fullscreen mode Exit fullscreen mode

This is where I did some actual debugging.

First suspect: the database firewall. I added an explicit rule allowing Azure services and restarted:

az postgres flexible-server firewall-rule create \
  --resource-group notes-prod-rg \
  --server-name notes-db-$SUFFIX \
  --name AllowAllAzureIPs \
  --start-ip-address 0.0.0.0 \
  --end-ip-address 0.0.0.0

az webapp restart --resource-group notes-prod-rg --name notes-app-$SUFFIX
az webapp start --resource-group notes-prod-rg --name notes-app-$SUFFIX
Enter fullscreen mode Exit fullscreen mode

Looking back, this rule was redundant. The --public-access 0.0.0.0 flag at server creation had already created an equivalent one. Still, ruling out the firewall was worth the minute it took.

Second suspect: the app's actual state. Instead of guessing, I asked Azure:

az webapp show \
  --resource-group notes-prod-rg \
  --name notes-app-$SUFFIX \
  --query "state"
Enter fullscreen mode Exit fullscreen mode

The answer was:

"QuotaExceeded"
Enter fullscreen mode Exit fullscreen mode

Cause: the Free (F1) tier has a daily compute quota. The app had used it up, and Azure stopped it. The 503 was only a symptom.

Fix: move the plan up one tier:

az appservice plan update \
  --name notes-app-plan \
  --resource-group notes-prod-rg \
  --sku B1
Enter fullscreen mode Exit fullscreen mode

B1 is a paid tier with no daily CPU quota. For a free-tier demo, remember that F1 can quietly stop your app.

The firewall rule, the restart and start attempts, the QuotaExceeded state and the plan upgrade:

Along the way the browser showed a different message, because Azure had stopped the app:

Error 403 - This web app is stopped.
Enter fullscreen mode Exit fullscreen mode

Step 5: Re-point the container and restart

With the plan upgraded, I re-applied the container configuration explicitly, pointing at the exact image tag, and restarted:

az webapp config container set \
  --resource-group notes-prod-rg \
  --name notes-app-$SUFFIX \
  --docker-custom-image-name 4thman/notes-app:V1

az webapp restart --resource-group notes-prod-rg --name notes-app-$SUFFIX
Enter fullscreen mode Exit fullscreen mode

Note the tag: V1 with a capital V. Docker tags are case-sensitive, and some of my earlier commands used lowercase v1 while the tag I actually pushed is V1. I corrected these as I went, but it's a classic way to get "image not found" errors, so copy the tag exactly.

This time the site came up on https://notes-app-<suffix>.azurewebsites.net, with HTTPS provided by Azure for free:

I added a note ("my name" / "My name is David"), refreshed, and it persisted in Azure Database for PostgreSQL:

Cleanup

az group delete --name notes-prod-rg --yes --no-wait
Enter fullscreen mode Exit fullscreen mode

Deleting the resource group removes the plan, web app and database together, so nothing keeps billing.


Part 5: Azure Virtual Machine + Docker Compose

The third approach gives you the most control and the most responsibility. It's just a Linux server, and I install everything myself.

Create the VM

az group create --name notes-vm-rg --location eastus

az vm create \
  --resource-group notes-vm-rg \
  --name notes-vm \
  --image Ubuntu2204 \
  --admin-username azureuser \
  --generate-ssh-keys \
  --size Standard_B1s
Enter fullscreen mode Exit fullscreen mode

--generate-ssh-keys creates an SSH key pair, so there's no password login to worry about. The output shows the VM running with a private and public IP.

Open port 3000

A new VM blocks inbound traffic by default. The app listens on 3000, so I opened that port in the network security group:

az vm open-port \
  --resource-group notes-vm-rg \
  --name notes-vm \
  --port 3000
Enter fullscreen mode Exit fullscreen mode

The output lists a new inbound rule called open-port-3000 that allows TCP traffic to port 3000.

Connect with SSH

VM_IP=$(az vm show \
  --resource-group notes-vm-rg \
  --name notes-vm \
  --show-details \
  --query publicIps --output tsv)

ssh azureuser@$VM_IP
Enter fullscreen mode Exit fullscreen mode

The prompt changed to azureuser@notes-vm:~$, which means I was now inside the VM.

Install Docker

I followed Docker's official apt repository instructions. First, update the package index:

sudo apt-get update
Enter fullscreen mode Exit fullscreen mode

Then the prerequisites:

sudo apt-get install -y ca-certificates curl gnupg
Enter fullscreen mode Exit fullscreen mode

Next, Docker's signing key and repository:

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

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
Enter fullscreen mode Exit fullscreen mode

The key lets apt verify that packages are genuine, and the echo adds Docker's repository to apt's sources.

Finally, install the engine, the CLI, containerd and the Compose plugin:

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
Enter fullscreen mode Exit fullscreen mode

Error #4: Docker permission denied (the group problem)

By default only root can talk to the Docker daemon, which means every command needs sudo. The fix is adding your user to the docker group:

sudo usermod -aG docker $USER
newgrp docker
Enter fullscreen mode Exit fullscreen mode

Group changes normally apply on your next login. newgrp docker applies it to the current shell immediately, so you don't have to log out and back in. Then I verified everything:

docker --version          # Docker version 29.8.2
docker compose version    # Docker Compose version v5.5.1
docker ps                 # empty list, no permission errors
Enter fullscreen mode Exit fullscreen mode

Deploy with Compose

On the VM I created a folder and a Compose file that pulls my image from Docker Hub, with no build step needed:

mkdir notes-app && cd notes-app
nano docker-compose.yml
Enter fullscreen mode Exit fullscreen mode
services:
  app:
    image: 4thman/notes-app:V1
    command: sh -c "sleep 15 && node server.js"
    ports:
      - "3000:3000"
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: appuser
      DB_PASSWORD: <your-password>
      DB_NAME: notesdb
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: <your-password>
      POSTGRES_DB: notesdb
    volumes:
      - db_data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  db_data:
Enter fullscreen mode Exit fullscreen mode

Notice the VM runs its own Postgres container, not Azure's managed database. The extra sleep 15 gives Postgres time to initialise on first start, since depends_on only waits for the container to start, not for the database to be ready.

Then:

docker compose up -d
docker compose ps
Enter fullscreen mode Exit fullscreen mode

Compose pulled both images (postgres:16-alpine and 4thman/notes-app:V1), created the network and volume, and started both containers. docker compose ps showed the app mapped to 0.0.0.0:3000->3000/tcp.

I opened http://<VM-IP>:3000 and there was the Notes app, running on a server I had built from scratch. I filled in a note titled "hello from the vm" to try it out.

Cleanup

az group delete --name notes-vm-rg --yes --no-wait
az group list --output table
Enter fullscreen mode Exit fullscreen mode

While the group showed Deleting, I also noticed a NetworkWatcherRG group that Azure creates automatically when you build networks. I deleted that too and re-listed groups until both were gone:

az group delete --name NetworkWatcherRG --yes --no-wait
Enter fullscreen mode Exit fullscreen mode


Pushing the project to GitHub

Finally, I put everything under version control. I created a .gitignore, initialised the repository and made the first commit:

touch .gitignore
git init
git add .
git commit -m "saving the files for the first time"
git branch main
git switch main
Enter fullscreen mode Exit fullscreen mode

Then I created an empty repository on GitHub:

And connected and pushed:

git remote add origin https://github.com/4thman/4thman-dockerised-notes-web-application.git
git push -u origin main
Enter fullscreen mode Exit fullscreen mode


Errors at a glance

# What I saw Cause Fix
1 Container restarting, Cannot find module 'pg' Driver missing from package.json npm install express pg, then docker compose up -d --build
2 F1 quota limit 0 in East US No free-tier capacity in that region Created the plan in Central US
3 503 Service Unavailable App had been stopped (QuotaExceeded on the F1 tier) Upgraded the plan to B1, re-set the container, restarted
4 Docker commands need sudo User not in the docker group sudo usermod -aG docker $USER and newgrp docker
5 Risk of "image not found" Tag case mismatch (v1 vs V1) Used the exact tag V1 everywhere
6 App crashing before the DB was ready Containers start at the same time sleep before node server.js (ACI and VM)

What learned

  1. Configuration through environment variables is the whole game. One image ran in three environments because nothing environment-specific was baked into it.
  2. Read the logs before you change anything. docker compose logs, az container logs and az webapp show --query state each pointed straight at the problem.
  3. A symptom isn't a cause. The 503 looked like a database or networking issue. It was a billing-tier quota.
  4. Managed services save work but add settings. App Service handled HTTPS and the OS, but I had to learn about WEBSITES_PORT, SSL and plan tiers.
  5. VMs give control and take your time. Docker installation, permissions, firewall rules and updates were all mine to handle.
  6. Clean up after yourself. Deleting resource groups is the easiest way to avoid surprise bills.

Your Turn

That’s where I’ll leave this one.

I started with a Notes app running on my machine and ended up deploying the same Docker image through ACI, Azure App Service with managed PostgreSQL, and an Azure VM. Along the way, I broke things, read the logs, fixed them, and learned something new from every failure.


Try it yourself


Hit one of these errors while deploying your own Docker app? Or did you find a smarter fix that I missed? Drop your experience, workaround, or “I’ve been there” story in the comments. I’d love to compare notes and learn from you.

Make sure you kindly, give it a like, share it with someone learning Docker or Azure, and let me know what you’d like to see me build or break next.

Until the next deployment, keep building, keep testing, and don’t be afraid of the errors. They’re often where the real learning happens.

Top comments (0)