# 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](https://github.com/4thman/4thman-dockerised-notes-web-application.git) **Docker image:** [4thman/notes-app on Docker Hub](https://hub.docker.com/r/4thman/notes-app)

* * *

## 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.

![](https://cdn.hashnode.com/uploads/covers/6a0386cf937b84f779c770a4/7771588a-aca5-40d7-a87c-61f5bb9b50e5.jpg align="center")

| 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:

```bash
mkdir dockerapp-project
cd dockerapp-project
mkdir david-note
cd david-note
mkdir public
touch public/index.html Dockerfile server.js docker-compose.yaml
```

Creating the project folder and opening it in VS Code:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/pyt3ill0gbjs7351189i.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/hyx6pcbegq0rjfupi5tn.png align="center")

Creating the `Dockerfile` and `server.js`:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/de7lgo2uyjirmqbeqoct.png align="center")

You end up with this:

```plaintext
david-note/
├── public/
│   └── index.html
├── server.js
├── Dockerfile
└── docker-compose.yaml
```

### 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`.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/9v7mz5kzb306g3olfhmj.png align="center")

### 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:

```js
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,
});
```

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:

```js
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()
    );
  `);
}
```

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:

```js
app.get('/health', (req, res) => res.json({ status: 'ok' }));
```

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):

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0o8ig8tqyp3c7zk23bue.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8ci1ki1m1tiagqccf4ou.png align="center")

### The Dockerfile

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

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/9zhcusuegb7r3bqnqzdj.png align="center")

### The Compose file

```yaml
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:
```

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:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/gxkmaer36rqnj8vlizjc.png align="center")

The Compose script in the editor:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/jkup6h64c8rywe3knm0z.png align="center")

### Error #1: `Cannot find module 'pg'`

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vrzmbj1m9ijfgiya22zl.png align="center")

Then I built and started everything:

```bash
docker compose up -d --build
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/47ff5autvswxayvqhk48.png align="center")

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

```bash
docker compose ps
docker compose logs app
```

The logs gave the reason:

```plaintext
Error: Cannot find module 'pg'
Require stack:
- /app/server.js
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3qxzexy4jdk2ipqxd0r5.png align="center")

**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:**

```bash
npm install express pg
docker compose up -d --build
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/e2nwr2hy1bwp8p709zpj.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/rjbql71g1k2r36ycqqhk.png align="center")

**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:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/4yy6y0h7hhqu8fwwvwzm.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/4jolnksm8vfrfi7n22oy.png align="center")

Then I tore everything down:

```bash
docker compose down
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/30339bg20vzij41masvh.png align="center")

`localhost:3000` now showed `ERR_CONNECTION_REFUSED`:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/rsw4uythun5i3wcu131f.png align="center")

Then I brought it back:

```bash
docker compose up -d
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/9qpdq0d5aoi0p86doptw.png align="center")

Both notes were still there:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/9l2zlk0x4h01rinci6hh.png align="center")

`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:

```plaintext
node_modules
.git
.env
```

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/e2zmjt1owlny4hdbqfvh.png align="center")

Then I logged in and confirmed the image existed:

```bash
docker compose down
docker login
docker image ls
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/b3albekssxmt3b2m7wx8.png align="center")

And pushed it:

```bash
docker push 4thman/notes-app:V1
```

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/zkdsojsrjf1a17h0h1hy.png align="center")

The repository now shows up at [hub.docker.com/r/4thman/notes-app](https://hub.docker.com/r/4thman/notes-app) with the `V1` tag.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/yz27hylwch7crxif3oy0.png align="center")

* * *

## 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:

```bash
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
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vpg0apv8lm28cnske73b.png align="center")

### Variables and a resource group

To avoid retyping, I set shell variables:

```bash
DOCKER_USER="4thman"
SUFFIX="<your-unique-suffix>"
LOCATION="eastus"
DB_PASS="<a-strong-password>"
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8bta1r1aa0k86fu9k70r.png align="center")

Checking that they were set:

```bash
echo $DOCKER_USER $SUFFIX $LOCATION
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/zvda9b8b4bw5p61byhh3.png align="center")

Then the resource group:

```bash
az group create --name notes-app-rg --location $LOCATION
```

`provisioningState: Succeeded` in the output confirmed the group existed.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/bbvw61j0sj60ur3uqtm6.png align="center")

### 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`:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/c41uujcgy6l83tutok6w.png align="center")

```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
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6p3v4zug128to5zammi3.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/roiem113hrjaxdsl7sdc.png align="center")

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:

```bash
az container create --resource-group notes-app-rg --file aci-notes.yaml
```

`provisioningState: Succeeded` came back:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/5n0s3si9k9u50hzvq57w.png align="center")

Then I grabbed the address:

```bash
az container show \
  --resource-group notes-app-rg \
  --name notes-app-group \
  --query ipAddress.fqdn --output tsv
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/hjv8smesv8mtbk147e2l.png align="center")

And checked the logs:

```bash
az container logs \
  --resource-group notes-app-rg \
  --name notes-app-group \
  --container-name app
```

The logs showed exactly what I wanted:

```plaintext
Notes app running on port 3000
Database initialized (notes table ready)
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/dzxfg5rtafnm0w5rs01c.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kgemjfimwr2ftxrxbnue.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/nb4shwcpzm7eyqxalb6c.png align="center")

### 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:

```bash
az container delete --resource-group notes-app-rg --name notes-app-group --yes
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/pl2xjncu47d0l3wnniyi.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/17rqh0pk1btc5oe99bs2.png align="center")

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

```bash
az group delete --name notes-app-rg --yes --no-wait
az group create --name notes-prod-rg --location $LOCATION
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0xp4lj0jqz1kyao6f4to.png align="center")

* * *

## 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

```bash
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
```

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/p9k4mwx97lh001qpqecn.png align="center")

Then I created the application database:

```bash
az postgres flexible-server db create \
  --resource-group notes-prod-rg \
  --server-name notes-db-$SUFFIX \
  --name notesdb
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ephb4qj59t7g59hreijq.png align="center")

And captured the server address:

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

echo $DB_HOST
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kef9a7qcl7zu3chy3h5b.png align="center")

### Step 2: Create the App Service plan

### Error #2: free-tier quota in East US

```bash
az appservice plan create \
  --name notes-app-plan \
  --resource-group notes-prod-rg \
  --is-linux --sku F1 --location eastus
```

This failed:

```plaintext
Current Limit (F1 VMs): 0
Amount required for this deployment (F1 VMs): 1
```

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

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

```bash
az appservice plan create \
  --name notes-app-plan \
  --resource-group notes-prod-rg \
  --is-linux --sku F1 --location centralus
```

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/cvwvq0j3rstw5vf4pa43.png align="center")

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

```bash
az webapp create \
  --resource-group notes-prod-rg \
  --plan notes-app-plan \
  --name notes-app-$SUFFIX \
  --deployment-container-image-name 4thman/notes-app:V1
```

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/h2rtoicrnddz0n8ga5gp.png align="center")

### Step 4: Configure the app

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

```bash
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"
```

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`.
    

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xbs21q6cm3grjxqgf6hi.png align="center")

)

Then I restarted the app:

```bash
az webapp restart --resource-group notes-prod-rg --name notes-app-$SUFFIX
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/09ees41amur53pqe68o4.png align="center")

And printed the site URL to open it:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/tms85ccnyos9gvpion64.png align="center")

### Error #3: 503 Service Unavailable

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

```plaintext
503 Service Unavailable
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/lwg6dw4jyajmxiyaibsc.png align="center")

This is where I did some actual debugging.

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

```bash
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
```

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:

```bash
az webapp show \
  --resource-group notes-prod-rg \
  --name notes-app-$SUFFIX \
  --query "state"
```

The answer was:

```plaintext
"QuotaExceeded"
```

**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:

```bash
az appservice plan update \
  --name notes-app-plan \
  --resource-group notes-prod-rg \
  --sku B1
```

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:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/987qtxg0rp58of6qbhp4.png align="center")

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

```plaintext
Error 403 - This web app is stopped.
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/j2xnr8dcjuaucxfvjv0t.png align="center")

### 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:

```bash
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
```

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ohc0mkn6p4m13zai8t7e.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/tkwbsptngs2hdls0v897.png align="center")

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/v52fa9vt9h37sgmd7275.png align="center")

### Cleanup

```bash
az group delete --name notes-prod-rg --yes --no-wait
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/o97dlwaeji8u2blicr7k.png align="center")

* * *

## 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

```bash
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
```

`--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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/y88xxx9np5qcv5hitylx.png align="center")

### 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:

```bash
az vm open-port \
  --resource-group notes-vm-rg \
  --name notes-vm \
  --port 3000
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/t3jiid4rkfnyr1rhwzcz.png align="center")

### Connect with SSH

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

ssh azureuser@$VM_IP
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6qk0v7e7o3iywdbeiyau.png align="center")

### Install Docker

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

```bash
sudo apt-get update
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/w92tkkhrhuxmy4ym1erj.png align="center")

Then the prerequisites:

```bash
sudo apt-get install -y ca-certificates curl gnupg
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/m484z3x0fugytt9iqby2.png align="center")

Next, Docker's signing key and repository:

```bash
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
```

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

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xvw9pp4xeijgsoqhuoej.png align="center")

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

```bash
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/pmxu77h19dmu3j93bvr6.png align="center")

### 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:

```bash
sudo usermod -aG docker $USER
newgrp docker
```

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:

```bash
docker --version          # Docker version 29.8.2
docker compose version    # Docker Compose version v5.5.1
docker ps                 # empty list, no permission errors
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/bsmpg3a71n4bgemjuq1c.png align="center")

### 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:

```bash
mkdir notes-app && cd notes-app
nano docker-compose.yml
```

```yaml
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:
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/80nd4pgg7qk6dual3gmr.png align="center")

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:

```bash
docker compose up -d
docker compose ps
```

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`.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/wak14djnrb4omxa2m2ip.png align="center")

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.

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kual1pdp1d6iio0ne70k.png align="center")

### Cleanup

```bash
az group delete --name notes-vm-rg --yes --no-wait
az group list --output table
```

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:

```bash
az group delete --name NetworkWatcherRG --yes --no-wait
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/j55xbwvplprxm27lfufv.png align="center")

* * *

## Pushing the project to GitHub

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

```bash
touch .gitignore
git init
git add .
git commit -m "saving the files for the first time"
git branch main
git switch main
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/4sphu3du7l9qrq2ltt62.png align="center")

Then I created an empty repository on GitHub:

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/h89gouth40cavco2ok2p.png align="center")

And connected and pushed:

```bash
git remote add origin https://github.com/4thman/4thman-dockerised-notes-web-application.git
git push -u origin main
```

![Image description](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/gsagaz0sfa4nzy2qfarl.png align="center")

* * *

## 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

*   **Code:** [github.com/4thman/4thman-dockerised-notes-web-application](https://github.com/4thman/4thman-dockerised-notes-web-application.git)
    
*   **Image:** `docker pull 4thman/notes-app:V1` ([Docker Hub](https://hub.docker.com/r/4thman/notes-app))
    

* * *

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.**
