DEV Community

Cover image for Dockerizing a Node App & Shipping It to Azure: Build, Break, Fix & Ship (3 Errors, Zero Regrets) 🐳
David Cletus
David Cletus

Posted on

Dockerizing a Node App & Shipping It to Azure: Build, Break, Fix & Ship (3 Errors, Zero Regrets) 🐳

I wanted a hands-on project to actually cement what I'd been learning in Docker and Azure, build something small, break it, fix it, and ship it for real. So I built Container Vibes: a tiny Express app that hands you a random "vibe" (a color theme + a little quote) every time you click a button. It's deliberately simple. The point was never the app it was everything around it: containerizing it properly, pushing it to a registry, and getting it running live on Azure Container Instances (ACI).

This post walks through the entire build, step by step, in the exact order I actually did it including the three errors I ran into along the way and exactly how I diagnosed and fixed each one.

What we're building

  • A small Node.js/Express app with three routes: a homepage, a /health check, and a /api/vibe endpoint that returns a random theme
  • A Docker image for it, tested locally
  • The image pushed up to Docker Hub as the deployable artifact
  • The container running live on Azure Container Instances, reachable at a real URL
  • The source code version-controlled and pushed to GitHub

Step 1 — Laying the groundwork

First thing, I set up a clean folder structure so everything had a home:

mkdir docker-project-1 && cd docker-project-1
code .
Enter fullscreen mode Exit fullscreen mode

That opens the empty folder straight into VS Code. From there:

mkdir hagital && cd hagital
npm init -y
touch Dockerfile
Enter fullscreen mode Exit fullscreen mode

npm init -y scaffolds a package.json with all the defaults accepted automatically no need to answer the usual prompts one by one. This file is what tells both Node and, later, Docker's npm install step exactly which dependencies the project needs, so getting it created early matters. I also touched an empty Dockerfile right away, just as a placeholder for what was coming.

Then the actual app files:

touch server.js .dockerignore
Enter fullscreen mode Exit fullscreen mode

Step 2 — Writing the app

server.js is where all the actual logic lives. It's a small Express server holding a roster of "vibes" each one a name, a pair of gradient colors, and a short quote plus two routes: one that picks a vibe at random, and a health check.

const express = require('express');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 3000;

// A little roster of "container vibes" — color themes + quotes
const VIBES = [
  { name: 'Sunset Deploy', colors: ['#FF6B6B', '#FFD93D'], quote: 'Life is good and could be challenging — ship it anyway.' },
  { name: 'Ocean Rollout', colors: ['#4ECDC4', '#556270'], quote: 'Every container has a bad day. Restart policy: always.' },
  { name: 'Neon Nightshift', colors: ['#8E2DE2', '#4A00E0'], quote: "Logs don't lie. Neither do good tests." },
  { name: 'Forest Uptime', colors: ['#11998e', '#38ef7d'], quote: 'Healthy pods, happy engineers.' },
  { name: 'Candy CI/CD', colors: ['#f857a6', '#ff5858'], quote: 'Green pipeline, good vibes.' },
  { name: 'Golden Hour Git', colors: ['#f7971e', '#ffd200'], quote: 'Commit small, dream big.' },
  { name: 'Cosmic Cluster', colors: ['#0f0c29', '#302b63', '#24243e'], quote: 'Somewhere out there, a pod is scaling for you.' },
];

app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());

app.get('/api/vibe', (req, res) => {
  const vibe = VIBES[Math.floor(Math.random() * VIBES.length)];
  res.json({
    ...vibe,
    timestamp: new Date().toISOString(),
    uptime: Math.floor(process.uptime()),
  });
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok', message: 'Life is good and could be challenging. But we are up.' });
});

app.listen(PORT, () => {
  console.log(`✨ Server running on port ${PORT} — go visit http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

A few things worth pointing out: express.static serves everything in the public folder (that's how the HTML page gets delivered), /api/vibe picks a random entry from the VIBES array and tacks on a live timestamp and the server's uptime so every response feels fresh, and /health exists purely as a lightweight endpoint to confirm the server is alive which turned out to be genuinely useful later when checking on the container from Azure's side.

Step 3 — Building the frontend

mkdir public && cd public
touch index.html
cd ..
Enter fullscreen mode Exit fullscreen mode

public/index.html is a single self-contained page: a gradient background that slowly animates through the current vibe's colors, a frosted-glass "card" in the center holding the vibe's name and quote, a small uptime readout, and a "New Vibe" button. Clicking that button calls /api/vibe, gets back a fresh random theme as JSON, and updates the card's colors and text on the page without a reload.

Step 4 — Writing the Dockerfile

# pull the official Node.js image from the Docker Hub
FROM node:20-alpine

# set the working directory in the container
WORKDIR /app

# copy package.json and package-lock.json to the working directory
COPY package*.json ./

# install dependencies
RUN npm install

# copy the rest of the application code to the working directory
COPY . .

# expose the port that the application will run on
EXPOSE 3000

# define the command to run the application
CMD [ "node", "server.js" ]
Enter fullscreen mode Exit fullscreen mode

Walking through it line by line: FROM node:20-alpine grabs a minimal, lightweight Node 20 base image (the Alpine variant strips out everything not strictly needed, keeping the image small). WORKDIR /app sets where all the following commands run inside the container. COPY package*.json ./ copies over just package.json and package-lock.json first — deliberately, before the rest of the code so that RUN npm install becomes its own cached layer. That means if I only change server.js later and rebuild, Docker can reuse the already-installed node_modules layer instead of reinstalling everything from scratch every single time. COPY . . then brings in the rest of the app. EXPOSE 3000 documents which port the app listens on. And CMD [ "node", "server.js" ] is what actually runs when a container starts from this image.

Step 5 — First build, first surprise

With the Dockerfile in place, I moved back up to the project root and kicked off the build:

cd ..
docker build -t 4thman/hagital:v1 .
Enter fullscreen mode Exit fullscreen mode

It built clean — all 11 steps finished, image tagged as 4thman/hagital:v1.

Feeling good, I ran it and checked the images list first:

docker images
docker run -d -p 3000:3000 --name hagital-app 4thman/hagital:v1
Enter fullscreen mode Exit fullscreen mode

Then opened localhost:3000 in the browser, fully expecting my little vibes card to show up. Instead: "This site can't be reached localhost refused to connect."

Debugging: the case of the missing module

A clean docker build doesn't guarantee a working app that's the lesson this error taught me. First move was checking whether the container was even still alive:

docker ps -a
Enter fullscreen mode Exit fullscreen mode

It showed status Exited (1), meaning the container had started and then immediately crashed. Time to check why:

docker logs hagital-app
Enter fullscreen mode Exit fullscreen mode
Error: Cannot find module 'express'
Enter fullscreen mode Exit fullscreen mode

And there it was. I had written require('express') in server.js, but I had never actually run npm install express on my own machine so package.json had zero dependencies listed. When the Dockerfile's RUN npm install step ran during the build, there was simply nothing in package.json for it to install. The image built successfully because Docker didn't know anything was missing it only found out at runtime, the moment Node tried to require() a package that was never there.

The fix:

npm install express
Enter fullscreen mode Exit fullscreen mode

That updates package.json and package-lock.json with the real dependency. Then it was just a matter of rebuilding the image (this time you could see only the WORKDIR layer got reused from cache everything from COPY package*.json onward had to rerun, since the package files had actually changed) and swapping the container:

docker build -t 4thman/hagital:v1 .
docker rm hagital-app
docker run -d -p 3000:3000 --name hagital-app 4thman/hagital:v1
Enter fullscreen mode Exit fullscreen mode

Success — locally, at last

Refreshed the browser and there it was: Container Vibes, running from inside a container, gradient background and all.

I went through all three routes to be thorough the homepage, the health check, and the vibe API directly:

(homepage) ·

(/health returning {"status":"ok",...}) ·

(/api/vibe returning a random theme as JSON)*

Quick concept check: EXPOSE vs -p vs --dns-name-label

These three get mixed up a lot, but they each solve a different problem. EXPOSE in the Dockerfile is really just documentation it tells anyone reading the image which port the app inside expects traffic on, but it doesn't actually open anything to the outside world by itself; that's exactly why the app could build fine and still be unreachable. The -p flag on docker run is the one doing the real work locally: it's the bridge that maps a port on your own machine to a port inside the container (-p 3000:3000 means "traffic hitting port 3000 on my laptop gets forwarded into the container's port 3000"), and without it your app can be running perfectly inside the container and you'd still get "connection refused" on the host. --dns-name-label, which comes in later once we're on Azure, is a different beast entirely since Azure Container Instances hands your container a public IP that's a pain to remember and isn't guaranteed to stay the same, this flag lets you attach a friendly, stable subdomain to it instead (like david-hagital.eastus.azurecontainer.io), so you're never hunting down a raw IP address to share a link.

Step 6 — Cleaning up locally

Before moving on, I ran through the basic container lifecycle commands just to get comfortable with them checking status, reading logs, stopping, and removing:

docker ps
docker logs hagital-app
docker stop hagital-app
docker rm hagital-app
docker ps
Enter fullscreen mode Exit fullscreen mode

That last docker ps came back empty, confirming the container was fully gone.

Step 7 — Logging into Docker Hub

Next was getting the image somewhere Azure could actually reach it. Logged in, and while I was at it, ran git init on the project too (even though I wouldn't actually commit anything until much later) and double-checked my .dockerignore set to keep .git and .env out of the build context entirely, so neither ever ends up baked into the image.

docker login
git init
Enter fullscreen mode Exit fullscreen mode
.dockerignore
.git
.env
Enter fullscreen mode Exit fullscreen mode

Step 8 — Pushing to Docker Hub

docker push 4thman/hagital:v1
Enter fullscreen mode Exit fullscreen mode

Watched each layer push up one by one until it finished with a digest and total size the image was now sitting on Docker Hub, ready to be pulled from anywhere, including Azure.

Step 9 — Logging into Azure

az login
Enter fullscreen mode Exit fullscreen mode

This opens a browser sign-in flow and, once authenticated, lists the subscriptions tied to the account so you can confirm which one the CLI is about to operate on.

Step 10 — Creating the resource group (and hitting error #1)

az group create --name david-docker-rg --location eastus
Enter fullscreen mode Exit fullscreen mode

That part succeeded instantly — a resource group is just a logical folder in Azure to keep everything for this project together. Then I tried to actually create the container:

az container create \
  --resource-group david-docker-rg \
  --name david-app-container \
  --image 4thman/hagital:v1 \
  --port 3000 \
  --dns-name-label david-hagital \
  --cpu 1 \
  --memory 1
Enter fullscreen mode Exit fullscreen mode

And hit the first Azure error:

(MissingSubscriptionRegistration) The subscription is not registered to use namespace 'Microsoft.ContainerInstance'
Enter fullscreen mode Exit fullscreen mode

Fixing error #1: the provider that wasn't switched on

Every Azure subscription has to explicitly "register" the individual resource providers it wants to use before you're allowed to create resources of that type. Mine had simply never touched Container Instances before, so the Microsoft.ContainerInstance namespace wasn't switched on yet. The fix:

az provider register --namespace Microsoft.ContainerInstance
az provider show -n Microsoft.ContainerInstance --query "registrationState"
Enter fullscreen mode Exit fullscreen mode

I ran that last command a couple of times, watching the state move from "Registering" to "Registered" before trying again.

Error #2: pick an OS, any OS

With the provider registered, I ran the exact same az container create command again and hit a second, different error:

(InvalidOsType) The 'osType' for container group '<null>' is invalid. The value must be one of 'Windows,Linux'.
Enter fullscreen mode Exit fullscreen mode

It turns out az container create doesn't assume an operating system for you, you have to say so explicitly, every time. Adding --os-type Linux to the command fixed it immediately.

az container create \
  --resource-group david-docker-rg \
  --name david-app-container \
  --image 4thman/hagital:v1 \
  --port 3000 \
  --dns-name-label david-hagital \
  --os-type Linux \
  --cpu 1 \
  --memory 1
Enter fullscreen mode Exit fullscreen mode

It worked — container created

This time the command ran all the way through, returning the full container group JSON with "provisioningState": "Succeeded".

Step 11 — Grabbing the live URL

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

That returned david-hagital.eastus.azurecontainer.io — the friendly subdomain the --dns-name-label flag from earlier had reserved.

It's alive on the internet 🎉

Visited david-hagital.eastus.azurecontainer.io:3000 in the browser, and there it was Container Vibes, live, running the Forest Uptime theme.

Clicked "New Vibe" a few times to make sure the randomization actually worked over a real network call, not just locally and it did, cycling through themes like Golden Hour Git exactly as expected.

Step 12 — Checking logs straight from Azure

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

This pulled back the exact same startup message the app prints locally, confirming the container had booted cleanly on Azure's side too.

Step 13 — Tearing it down

Azure Container Instances bills for the time a container is actually running, so once I'd confirmed everything worked, I cleaned up:

az container delete \
  --resource-group david-docker-rg \
  --name david-app-container \
  --yes
Enter fullscreen mode Exit fullscreen mode


az group delete \
  --name david-docker-rg \
  --yes \
  --no-wait
Enter fullscreen mode Exit fullscreen mode

--no-wait just means the CLI hands control back to me immediately instead of sitting there until Azure finishes deleting everything in the background.

Step 14 — Finally, version control

With the app built, tested, containerized, pushed to Docker Hub, and successfully deployed and torn down on Azure, I went back and properly set up Git since none of the actual deployment steps above needed GitHub at all (Azure was pulling the image straight from Docker Hub), but I still wanted the source code backed up and version-controlled.

Added a proper .gitignore to keep node_modules and .env out of the repo, then checked status and staged everything:

touch .gitignore
git status
git add .
Enter fullscreen mode Exit fullscreen mode


git commit -m "Hagital Docker App"
Enter fullscreen mode Exit fullscreen mode

Seven files went into that first commit: the .dockerignore, .gitignore, Dockerfile, package.json, package-lock.json, public/index.html, and server.js.

Created a new private repository on GitHub named Hagital-Docker-App, with a short description.

Then connected the local repo to it and pushed:

git branch main
git switch main
git remote add origin https://github.com/4thman/Hagital-Docker-App.git
Enter fullscreen mode Exit fullscreen mode


git push -u origin main
Enter fullscreen mode Exit fullscreen mode

And confirmed everything landed correctly by checking the repo on GitHub, all seven files present, matching exactly what was committed locally.

What I'd tell past-me

None of the three errors here were Docker or Azure being difficult — they were small gaps in my own checklist. Forgetting to run npm install before building meant a "successful" build that shipped a broken app, and the fix was realizing that a clean build log means nothing if the container crashes the moment it actually starts. Forgetting that a fresh Azure subscription needs its resource providers registered cost me a re-run, and forgetting to specify an OS type cost me another. None of it was complicated once I actually read the error message properly — which, honestly, is most of DevOps in a nutshell: read the log, trust what it's telling you, fix that one specific thing, and try again.

Links

If you enjoyed following along, drop a ❤️, leave a comment, and share this with someone. I'd also love to know: what would you add or change in this project if you were building it yourself?

Until the next build keep learning, keep breaking things, keep fixing them, and keep shipping.

Top comments (1)

Collapse
 
member_39a7a922 profile image
member_39a7a922 •

This is so great