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: - Localhost with Docker Compose - Azure App Service for Containers with Azure Database for PostgreSQL (managed PaaS) - 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 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 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, }); 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() );); } 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' })); 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"] 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: DB_NAME: notesdb depends_on: - db restart: unless-stopped db: image: postgres:16-alpine environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: POSTGRES_DB: notesdb volumes: - db_data:/var/lib/postgresql/data restart: unless-stopped volumes: db_data: Three details worth pointing out: - build: . andimage: together. Compose builds the image from the local Dockerfile and tags it4thman/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 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 The logs gave the reason: Error: Cannot find module 'pg' Require stack: - /app/server.js 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 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 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 localhost:3000 now showed ERR_CONNECTION_REFUSED : Then I brought it back: docker compose up -d 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 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 And pushed it: 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. 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 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=" " LOCATION="eastus" DB_PASS=" " Checking that they were set: echo $DOCKER_USER $SUFFIX $LOCATION Then the resource group: az group create --name notes-app-rg --location $LOCATION 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: " " - name: DB_NAME value: notesdb ports: - port: 3000 resources: requests: cpu: 1.0 memoryInGb: 1.0 - name: db properties: i
Comments
No comments yet. Start the discussion.