Containerize a Full-Stack Task Tracker

Containerize a Vue, Express, and PostgreSQL task tracker with Docker Compose.

Introduction

30 Second Summary

An app can work perfectly on one computer yet refuse to cooperate on another because each machine is set up differently. Sharing useful software should feel easier than rebuilding every piece it needs.

In this project, you will use Docker Compose to containerize a browser-based task tracker built with Vue, Express, and PostgreSQL. Your stack will keep its task data after every container is replaced.

What You'll Build

At http://localhost:8080, you will see each browser-submitted task return from PostgreSQL after the application containers are replaced.

By the end of this project, you'll have:

  • A live task tracker where submitting a title immediately adds the task returned from PostgreSQL.
  • A production Vue bundle served by NGINX from a lean multi-stage image at http://localhost:8080.
  • A reusable Compose stack whose service-name networking keeps all three containers connected while a named volume preserves saved tasks.
  • Secret Mission: Gate the frontend on an API health check so the interface becomes available only after the API is healthy.

Are there any prerequisites?

You need a supported 64-bit Windows device with WSL 2 capability, hardware virtualization enabled, and at least 8 GB of RAM. You should already be comfortable with Node.js, Express, Vue, and PostgreSQL.

Before We Start

This step is your moment to commit to building one portable Docker Compose task tracker. Your goal is to understand how Vue, Express, PostgreSQL, and containers communicate while preserving task data.

Set Up the Windows Docker Workspace

Your task tracker will run inside three Linux containers. Windows needs WSL 2 to provide the Linux environment that hosts them.

Your workspace needs Docker Desktop to supply Docker Engine. Docker Compose will coordinate the application services later.

You will also prepare Windows PowerShell for commands. Visual Studio Code plus Git will support the source files you build next.

In this step, get ready to:
  • Verify that Windows can run WSL 2.
  • Install Docker Desktop with Docker Compose.
  • Install Visual Studio Code plus Git for Windows.
Verify WSL and Windows readiness

WSL 2 gives Docker Desktop a Linux environment on Windows. Docker Desktop requires WSL version 2.1.5 or later for this backend.

What does Windows need?

  • A supported 64-bit Windows client is required.
  • The processor needs SLAT support.
  • The device needs at least 8 GB RAM.
  • Hardware virtualization must be enabled.
  • Review Docker Desktop's Windows requirements to confirm that your device meets each requirement.
  • Press the Windows key to open Windows Search.
  • Type PowerShell into Windows Search.
  • Press Enter to open PowerShell.
  • Check the installed WSL version by running:
wsl --version

What does this command check?

This command prints the installed WSL components with their versions. The result determines whether WSL is ready or needs an update.

✔️ I see WSL 2.1.5 or higher

Good. Your WSL layer is current enough for the Docker Desktop backend.

ⓧ I see an older WSL version

Administrator access changes Windows system components. This update only refreshes the WSL components that Docker Desktop needs.

  • Close the current PowerShell window.
  • Launch PowerShell with administrator privileges from Windows Search.
  • Approve the Windows permission request.
  • Update WSL by running:
wsl --update

What does this update do?

This command downloads the current WSL components. It brings an older installation up to the version required by Docker Desktop.

  • Restart Windows if the update requests it.
  • Return to PowerShell after the restart.
  • Check the WSL version again by running:
wsl --version

What confirms readiness?

The updated version output confirms whether WSL now meets the minimum requirement. Continue when the version is 2.1.5 or later.

ⓧ WSL is not installed

Administrator access is required because WSL adds Windows system components. Windows may request a restart after the installation.

  • Close the current PowerShell window.
  • Launch PowerShell with administrator privileges from Windows Search.
  • Approve the Windows permission request.
  • Install WSL by running:
wsl --install

What does this installation do?

This command enables the Windows features required by WSL. It also installs the Linux environment that Docker Desktop can use.

  • Restart Windows if the installation requests it.
  • Return to PowerShell after the restart.
  • Confirm the WSL installation by running:
wsl --version

What confirms the installation?

The version output confirms that Windows can now access WSL. Continue when the reported version is 2.1.5 or later.

Install Docker Desktop and Compose

Docker Desktop packages Docker Engine with the Docker command-line tools. It also includes Docker Compose for managing several services as one application.

What does Docker Desktop cost?

Docker Desktop is free for personal learning. Commercial use in larger enterprises requires a paid subscription.

  • Check whether Docker Engine is already available by running:
docker version

What does this command check?

This command checks the Docker client plus its connection to Docker Engine. A client result without a server result means the engine is unavailable.

✔️ I see client and server details

Docker Engine is responding. That confirms Docker Desktop is already installed and running.

  • Open Docker Desktop settings to confirm that the WSL 2 backend is enabled.
  • Confirm that Docker Desktop reports Linux containers as active.

Your Docker engine now matches the environment this project uses.

ⓧ The docker command is unavailable

The installer may ask for administrator approval. It may also request a Windows restart.

  • Open the official Docker Desktop installation guide.
  • Download the Windows installer.
  • Run the downloaded installer.
  • Choose the recommended per-user installation.
  • Keep the WSL 2 backend enabled.
  • Restart Windows if the installer requests it.
  • Press the Windows key after the restart.
  • Type Docker Desktop into Windows Search to start it.

What gets installed?

Docker Desktop installs Docker Engine with the Docker command-line tools. It also provides Docker Compose through the same installation.

The first start prepares the WSL backend. Wait until Docker Desktop reports that the engine is running.

  • Confirm that the new installation responds by running:
docker version

What confirms Docker is running?

Client details confirm that the command-line tool is installed. Server details confirm that Docker Engine is running.

You should now see information for both the client and server.

ⓧ The client cannot reach the engine

Docker Desktop is installed in this state. Its engine still needs to start before containers can run.

  • Press the Windows key to open Windows Search.
  • Type Docker Desktop into Windows Search.
  • Press Enter to start Docker Desktop.
  • Wait until Docker Desktop reports that the engine is running.
  • Check the engine connection again by running:
docker version

What changes after startup?

Docker Engine supplies the server portion of this output. Seeing both sections confirms that the command-line tool can reach the engine.

  • Verify that Docker Compose is available by running:
docker compose version

What does this command prove?

This command asks the Docker command-line tool for its Compose version. A printed version confirms that Compose is ready to coordinate the task tracker services.

You should see a Docker Compose version number in PowerShell.

Docker Compose still unavailable?

Restart PowerShell so it can load the updated command path. Restart Docker Desktop if the engine is still unavailable.

Ask for help: Help me diagnose why Docker Desktop is running but docker compose version does not print a version.

Install the editor and open the workspace

Visual Studio Code provides the workspace for your application files. Git for Windows gives the project local source-control commands.

Pinned containers provide Node.js plus PostgreSQL later. Your Windows device only needs the editing tools in this substep.

  • Download the Windows User setup from the official Visual Studio Code installation page.
  • Run the Visual Studio Code User setup.
  • Download the x64 installer from the official Git for Windows installation page.
  • Run the Git for Windows installer.
  • Close PowerShell after both installations finish.
  • Press the Windows key to reopen Windows Search.
  • Type PowerShell into Windows Search to start a fresh terminal.
  • Verify Git by running:
git --version

What does this command prove?

This command asks Git to print its installed version. A version number confirms that the fresh PowerShell session can find Git.

You should see a Git version number in PowerShell.

Git command unavailable?

Close every PowerShell window after the installation. Open a new PowerShell window so Windows reloads the command path.

Ask for help: Help me diagnose why git --version is unavailable after installing Git for Windows.

  • Move to your Desktop by running:
Set-Location -Path "$HOME\Desktop"

What does this command do?

This command changes PowerShell's current location to your Desktop. The project folder created next will be easy to find.

  • Create the task-tracker folder by running these commands:
New-Item -Path .\task-tracker -ItemType Directory
Set-Location -Path .\task-tracker

What do these commands do?

  • The first command creates the task-tracker folder on your Desktop.
  • The second command moves PowerShell into that folder.

PowerShell prints the new directory details. Its name should be task-tracker.

  • Open the current folder in Visual Studio Code by running:
code .

What does this command do?

The code command starts Visual Studio Code. The final dot tells it to use the current task-tracker folder as the workspace.

You should see task-tracker open as the workspace in Visual Studio Code.

Visual Studio Code did not open?

Restart PowerShell after installing Visual Studio Code. The User setup adds the code command to the Windows command path.

Ask for help: Help me diagnose why code . does not open my task-tracker folder in Visual Studio Code.

Before the final check, what do you expect Docker to print when it successfully runs its test container?

  • Run Docker's installation smoke test in PowerShell:
docker run hello-world

What does this smoke test do?

Docker Engine downloads the test image when needed. It creates a container that prints a confirmation message before exiting.

You should see Hello from Docker! in PowerShell. That confirms Docker Engine can run a Linux container through your Windows workspace.

Smoke test did not finish?

  • Confirm that Docker Desktop reports its engine as running.
  • Confirm that Docker Desktop is using Linux containers.
  • Check your network connection if Docker cannot download the test image.

Ask for help: Help me diagnose why docker run hello-world does not print Hello from Docker on Windows.

Your Windows workspace can now run containers from an open Visual Studio Code project. Next, you will package the Vue interface into its first production image.

Containerize the Vue Interface

Your Windows workspace is ready to run Docker containers. The next useful milestone is a browser interface you can see before the API and database join the stack.

You will build a Vue interface with Vite. A multi-stage build compiles the production bundle with Node.js. NGINX serves that bundle without carrying the build tools into the runtime image.

Why Build Inside Docker?

The pinned Node.js image provides the build environment. This keeps the project independent from any Node.js version installed on Windows.

The NGINX stage receives only the production output. Your final container stays focused on serving the interface.

In this step, get ready to:
  • Create the Vue source for the Task Tracker interface.
  • Package the production bundle in a multi-stage Docker image.
  • Run the interface at http://localhost:8080 with Docker Compose.
Create the Vue source

The frontend needs a package manifest before Vite can build it. The manifest pins Vue 3.5.43 plus Vite 8.3.4 for repeatable image builds.

  • Create a folder named frontend inside the workspace using the folder control in the VS Code Explorer sidebar.
  • Create package.json directly inside frontend using the file control in the Explorer sidebar.
  • Populate frontend/package.json with this package configuration:
{
  "name": "task-tracker-frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build"
  },
  "dependencies": {
    "vue": "3.5.43"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "6.0.10",
    "vite": "8.3.4"
  }
}

What Does This Configuration Do?

  • The build script asks Vite to produce the browser-ready bundle.
  • The dependency entries pin every frontend package used by the image build.
  • The module value enables the import syntax used by the source files.
  • Save frontend/package.json.
  • Confirm that package.json appears directly inside frontend in the Explorer sidebar.

Package File Missing?

Check that the file sits directly inside frontend. Confirm that the filename ends with .json.

Ask for help: Help me verify my frontend package file.

Vite starts from an HTML page with a mounting element. Vue renders the interface inside the element identified by app.

  • Create index.html directly inside frontend using the file control in the Explorer sidebar.
  • Populate frontend/index.html with this page structure:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Task Tracker</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

How Does the Page Start?

  • The app element becomes Vue's mounting point.
  • The module script loads /src/main.js as the application entry file.
  • Save frontend/index.html.
  • Confirm that index.html appears beside package.json in the Explorer sidebar.

HTML File in the Wrong Folder?

Move index.html directly into frontend if the Explorer sidebar shows it inside another folder.

Ask for help: Help me check my Vue HTML entry file.

Vite needs its Vue plugin to compile files with the .vue extension. The configuration registers that plugin for this project.

  • Create vite.config.js directly inside frontend using the file control in the Explorer sidebar.
  • Populate frontend/vite.config.js with this plugin configuration:
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [vue()],
}

Why Add the Vue Plugin?

The plugin teaches Vite how to process App.vue. The production build can then turn the component into browser assets.

  • Save frontend/vite.config.js.
  • Confirm that vite.config.js appears directly inside frontend.

Plugin Configuration Underlined?

Check that the filename is exactly vite.config.js. Compare the import path with the dependency name in package.json.

Ask for help: Help me compare my Vite configuration.

The application source belongs in frontend/src. Its entry file loads the main component plus the shared stylesheet.

  • Create a folder named src inside frontend using the folder control in the Explorer sidebar.
  • Create main.js inside frontend/src using the file control in the Explorer sidebar.
  • Populate frontend/src/main.js with this application entry code:
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

createApp(App).mount('#app')

How Does Vue Mount?

  • The imports load Vue's application creator plus the component and stylesheet.
  • The createApp(App).mount('#app') call renders the component inside the HTML mounting point.
  • Save frontend/src/main.js.
  • Confirm that main.js appears inside frontend/src.

Entry File Paths Not Matching?

Check that main.js sits inside frontend/src. Its relative imports expect App.vue plus style.css in the same folder.

Ask for help: Help me verify my Vue entry paths.

The component uses local reactive state for its first working interaction. Submitting the form creates a task row in the browser before the API exists.

  • Create App.vue inside frontend/src using the file control in the Explorer sidebar.
  • Start frontend/src/App.vue with this script block:
<script setup>
import { ref } from 'vue'

const tasks = ref([])
const newTask = ref('')
const loading = ref(false)
const error = ref('')

async function loadTasks() {
  loading.value = false
}

async function addTask() {
  const title = newTask.value.trim()
  if (!title) return

  tasks.value.unshift({
    id: Date.now(),
    title,
    created_at: new Date().toISOString(),
  })
  newTask.value = ''
}
</script>

How Does the Local State Work?

  • The tasks reference stores the rows displayed by the interface.
  • The newTask reference stores the current form value.
  • The loading reference controls the loading message.
  • The error reference controls the error message.
  • The addTask() function creates a local task with an identifier and timestamp.
  • Save frontend/src/App.vue.
  • Confirm that the script ends with the closing </script> tag.

Script Block Showing an Error?

Check the closing braces inside addTask(). Confirm that ref is imported from Vue.

Ask for help: Help me find the App.vue script error.

The template binds the form to that local state. Conditional elements display the active message before the task list appears.

  • Place the cursor below the closing </script> tag in frontend/src/App.vue.
  • Complete the component with this template block:
<template>
  <main class="shell">
    <section class="card">
      <p class="eyebrow">Docker Compose Lab</p>
      <h1>Task Tracker</h1>
      <p class="subtitle">Vue, Express, and PostgreSQL working as separate containers.</p>

      <form class="task-form" @submit.prevent="addTask">
        <input v-model="newTask" aria-label="Task title" placeholder="Add a task" />
        <button type="submit">Add task</button>
      </form>

      <p v-if="error" class="message error">{{ error }}</p>
      <p v-else-if="loading" class="message">Loading tasks...</p>
      <p v-else-if="tasks.length === 0" class="message">No tasks yet. Add the first one.</p>

      <ul v-else class="task-list">
        <li v-for="task in tasks" :key="task.id">
          <span>{{ task.title }}</span>
          <small>{{ new Date(task.created_at).toLocaleString() }}</small>
        </li>
      </ul>
    </section>
  </main>
</template>

What Does the Template Display?

  • The form binds its input to newTask.
  • Form submission calls addTask().
  • The conditional paragraphs display the current interface state.
  • The list renders each object stored in tasks.
  • Save frontend/src/App.vue.
  • Confirm that the file contains one script block followed by one template block.

Template Markup Underlined?

Check that every opening element has a matching closing element. Confirm that the template begins below the script block.

Ask for help: Help me find mismatched template elements.

The stylesheet gives the interface its layout and visual hierarchy. Each chunk below completes one visible part of the Task Tracker.

  • Create style.css inside frontend/src using the file control in the Explorer sidebar.
  • Start frontend/src/style.css with these page foundations:
:root {
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: #172033;
  background: #eef3f8;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input {
  font: inherit;
}

What Do the Page Foundations Control?

These rules establish the page typography and background. The form controls inherit the same font as the surrounding interface.

  • Save frontend/src/style.css.
  • Confirm that the final rule targets both button and input.

Stylesheet Showing a Syntax Warning?

Check that every declaration has a property and value. Confirm that each selector block has a closing brace.

Ask for help: Help me check the page foundation CSS.

  • Place the cursor after the closing brace of the input rule.
  • Add the shell and card styles below it:
.shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.card {
  width: min(680px, 100%);
  padding: 36px;
  border-radius: 24px;
  background: #ffffff;
  box-shadow: 0 22px 60px rgba(30, 48, 80, 0.14);
}

How Does the Card Take Shape?

The shell centers its content across the browser window. The card adds a maximum width with padding and a shadow.

  • Save frontend/src/style.css.
  • Confirm that the .card rule appears below .shell.

Card Rules Nested Accidentally?

Check that the closing brace for .shell appears before .card begins.

Ask for help: Help me check the shell and card styles.

  • Place the cursor after the closing brace of .card.
  • Add the card typography below it:
.eyebrow {
  margin: 0;
  color: #3568d4;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1 {
  margin: 8px 0 4px;
  font-size: clamp(2rem, 6vw, 3.25rem);
}

.subtitle {
  margin: 0 0 28px;
  color: #667085;
}

How Does the Text Hierarchy Work?

The eyebrow creates a compact label above the main heading. The subtitle adds space before the task form.

  • Save frontend/src/style.css.
  • Confirm that the .subtitle rule follows the h1 rule.

Typography Rules Not Applying?

Compare the eyebrow and subtitle class names with App.vue.

Ask for help: Help me compare my typography selectors.

  • Place the cursor after the closing brace of .subtitle.
  • Add the task form styles below it:
.task-form {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 12px;
}

.task-form input {
  min-width: 0;
  padding: 13px 15px;
  border: 1px solid #cfd7e6;
  border-radius: 12px;
}

.task-form button {
  padding: 13px 18px;
  border: 0;
  border-radius: 12px;
  color: #ffffff;
  background: #3568d4;
  cursor: pointer;
}

How Does the Form Stay Usable?

The input receives the flexible column. The button keeps the width required by its label.

Padding makes both controls easy to select. Rounded borders separate them from the card.

  • Save frontend/src/style.css.
  • Confirm that the button rule includes the background value #3568d4.

Form Styles Showing Warnings?

Check every property for a colon and value. Confirm that each declaration ends correctly.

Ask for help: Help me find the task form CSS error.

  • Place the cursor after the closing brace of .task-form button.
  • Add the message and task list styles below it:
.message {
  margin: 24px 0 0;
  color: #667085;
}

.error {
  color: #b42318;
}

.task-list {
  display: grid;
  gap: 12px;
  margin: 24px 0 0;
  padding: 0;
  list-style: none;
}

How Are Messages and Tasks Separated?

The message rules create consistent spacing for status text. The error class gives failures a distinct colour.

The task list removes the browser's default bullets. Its rows use a consistent gap.

  • Save frontend/src/style.css.
  • Confirm that the .task-list rule includes list-style: none.

List Bullets Still Present?

Check that list-style: none sits inside the .task-list rule. Confirm that the selector begins with a period.

Ask for help: Help me debug the task list styles.

  • Place the cursor after the closing brace of .task-list.
  • Add the task row styles below it:
.task-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 15px 16px;
  border: 1px solid #e4e9f2;
  border-radius: 14px;
  background: #f8fafc;
}

.task-list small {
  color: #667085;
  white-space: nowrap;
}

How Are Task Rows Organized?

Each row places the task title opposite its timestamp. The timestamp stays on one line when enough width is available.

  • Save frontend/src/style.css.
  • Confirm that .task-list small follows the task row rule.

Task Rows Missing Their Layout?

Check that the selector contains a space in .task-list li. This targets list items inside the task list.

Ask for help: Help me check the task row selectors.

  • Place the cursor after the closing brace of .task-list small.
  • Complete the responsive layout below it:
@media (max-width: 560px) {
  .card {
    padding: 24px;
  }

  .task-form {
    grid-template-columns: 1fr;
  }

  .task-list li {
    align-items: flex-start;
    flex-direction: column;
  }
}

What Happens on Narrow Screens?

The media query reduces the card padding. It stacks the task form into one column.

Task rows also stack vertically. This keeps long titles readable on small screens.

  • Save frontend/src/style.css.
  • Confirm that the final brace closes the responsive media query.

Responsive Block Not Closing?

Check the closing braces for the three nested selectors. Confirm that the final brace closes the media query.

Ask for help: Help me check the responsive CSS braces.

Build and serve the frontend

A multi-stage image gives the build and runtime phases separate responsibilities. Node.js compiles the source before NGINX receives the finished dist directory.

NGINX needs a server configuration that points to the production files. Its fallback rule returns index.html when a frontend route has no matching file.

  • Create nginx.conf directly inside frontend using the file control in the Explorer sidebar.
  • Populate frontend/nginx.conf with this static server configuration:
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

How Does NGINX Serve the Interface?

  • NGINX listens on container port 80.
  • The document root points to /usr/share/nginx/html.
  • The try_files rule falls back to index.html for frontend routes.
  • Save frontend/nginx.conf.
  • Confirm that nginx.conf appears directly inside frontend.

NGINX Configuration Misplaced?

Check that nginx.conf sits beside package.json. The Dockerfile copies it from that location.

Ask for help: Help me verify my NGINX configuration.

The Docker build context only needs source files and configuration. Ignoring generated directories keeps local output out of the image build.

  • Create .dockerignore directly inside frontend using the file control in the Explorer sidebar.
  • Populate frontend/.dockerignore with these entries:
node_modules
dist

What Stays Out of the Build Context?

The ignore file excludes local dependencies and earlier production output. The image recreates both from the pinned package configuration.

  • Save frontend/.dockerignore.
  • Confirm that the filename begins with a period in the Explorer sidebar.

Ignore File Not Visible?

Refresh the Explorer sidebar if the file does not appear. Confirm that the filename is exactly .dockerignore.

Ask for help: Help me check the Docker ignore file.

The Dockerfile connects the Node.js build stage to the NGINX runtime stage. Its cross-stage copy moves the compiled production bundle into the static-content directory.

  • Create Dockerfile directly inside frontend using the file control in the Explorer sidebar.
  • Populate frontend/Dockerfile with this multi-stage build:
FROM node:24.21.0-alpine3.24 AS build

WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:1.30.5-alpine3.24

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

What Does the Dockerfile Build?

  • The build stage uses Node.js 24.21.0 to install packages and run the Vite production build.
  • The runtime stage uses NGINX 1.30.5 to serve the completed bundle.
  • The cross-stage copy moves /app/dist into the NGINX static-content directory.
  • The final line documents container port 80.
  • Save frontend/Dockerfile.
  • Confirm that Dockerfile appears beside nginx.conf.

Dockerfile Named Incorrectly?

Check that the filename is exactly Dockerfile with no extension. Confirm that both image stages are present.

Ask for help: Help me compare my frontend Dockerfile.

Run the frontend with Compose

Docker Compose stores the frontend build settings and port mapping in one reusable file. Windows port 8080 forwards browser requests to NGINX on container port 80.

  • Create compose.yaml in the top-level workspace folder using the file control in the Explorer sidebar.
  • Populate compose.yaml with this frontend service:
services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"

How Does the Frontend Service Run?

The build context points Compose to frontend for its Dockerfile and source files.

The port mapping connects Windows port 8080 to port 80 inside the container.

  • Save compose.yaml.
  • Switch back to the PowerShell terminal in your Windows project workspace.
  • Validate the Compose model by running this command:
docker compose config

What Does Compose Validate?

The command parses compose.yaml and renders the resolved model. A rendered frontend service confirms that the YAML structure is valid.

You should see the resolved frontend service with its build context and port mapping. PowerShell should return to the prompt without a configuration error.

Compose Configuration Rejected?

Check the indentation in compose.yaml. Use spaces so frontend remains nested beneath services.

Ask for help: Help me debug my Compose structure.

The first build can take several minutes while Docker downloads the pinned images and installs the frontend packages. The terminal may pause between build stages while those layers finish.

Before you start the stack, what interface do you expect to see at http://localhost:8080?

  • Build the image and start the frontend by running this command:
docker compose up --build --detach

What Does the Start Command Do?

Compose builds the frontend image before creating its container. Detached mode leaves NGINX running after PowerShell returns to the prompt.

  • Enter http://localhost:8080 in your browser address bar.

You should see the Task Tracker card with its task input and Add task button. The empty state should read No tasks yet. Add the first one..

That first visible slice is working. NGINX is serving the production Vue bundle that Docker built inside the image.

Task Tracker Not Loading?

  • Confirm that Docker Desktop is still running with Linux containers.
  • Check that compose.yaml sits in the top-level workspace folder.
  • Check that the Dockerfile copies /app/dist into /usr/share/nginx/html.

Ask for help: Help me debug the containerized Task Tracker.

✔️ Awesome, I've got everything!

Your Task Tracker is running through NGINX. Double check that every project file is saved before moving on.

ⓧ I'd like to double check the full code

Compare each file below with your workspace. These files show the exact frontend state for this step.

services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
node_modules
dist
FROM node:24.21.0-alpine3.24 AS build

WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:1.30.5-alpine3.24

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Task Tracker</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}
{
  "name": "task-tracker-frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build"
  },
  "dependencies": {
    "vue": "3.5.43"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "6.0.10",
    "vite": "8.3.4"
  }
}
<script setup>
import { ref } from 'vue'

const tasks = ref([])
const newTask = ref('')
const loading = ref(false)
const error = ref('')

async function loadTasks() {
  loading.value = false
}

async function addTask() {
  const title = newTask.value.trim()
  if (!title) return

  tasks.value.unshift({
    id: Date.now(),
    title,
    created_at: new Date().toISOString(),
  })
  newTask.value = ''
}
</script>

<template>
  <main class="shell">
    <section class="card">
      <p class="eyebrow">Docker Compose Lab</p>
      <h1>Task Tracker</h1>
      <p class="subtitle">Vue, Express, and PostgreSQL working as separate containers.</p>

      <form class="task-form" @submit.prevent="addTask">
        <input v-model="newTask" aria-label="Task title" placeholder="Add a task" />
        <button type="submit">Add task</button>
      </form>

      <p v-if="error" class="message error">{{ error }}</p>
      <p v-else-if="loading" class="message">Loading tasks...</p>
      <p v-else-if="tasks.length === 0" class="message">No tasks yet. Add the first one.</p>

      <ul v-else class="task-list">
        <li v-for="task in tasks" :key="task.id">
          <span>{{ task.title }}</span>
          <small>{{ new Date(task.created_at).toLocaleString() }}</small>
        </li>
      </ul>
    </section>
  </main>
</template>
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

createApp(App).mount('#app')
:root {
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: #172033;
  background: #eef3f8;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input {
  font: inherit;
}

.shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.card {
  width: min(680px, 100%);
  padding: 36px;
  border-radius: 24px;
  background: #ffffff;
  box-shadow: 0 22px 60px rgba(30, 48, 80, 0.14);
}

.eyebrow {
  margin: 0;
  color: #3568d4;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1 {
  margin: 8px 0 4px;
  font-size: clamp(2rem, 6vw, 3.25rem);
}

.subtitle {
  margin: 0 0 28px;
  color: #667085;
}

.task-form {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 12px;
}

.task-form input {
  min-width: 0;
  padding: 13px 15px;
  border: 1px solid #cfd7e6;
  border-radius: 12px;
}

.task-form button {
  padding: 13px 18px;
  border: 0;
  border-radius: 12px;
  color: #ffffff;
  background: #3568d4;
  cursor: pointer;
}

.message {
  margin: 24px 0 0;
  color: #667085;
}

.error {
  color: #b42318;
}

.task-list {
  display: grid;
  gap: 12px;
  margin: 24px 0 0;
  padding: 0;
  list-style: none;
}

.task-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 15px 16px;
  border: 1px solid #e4e9f2;
  border-radius: 14px;
  background: #f8fafc;
}

.task-list small {
  color: #667085;
  white-space: nowrap;
}

@media (max-width: 560px) {
  .card {
    padding: 24px;
  }

  .task-form {
    grid-template-columns: 1fr;
  }

  .task-list li {
    align-items: flex-start;
    flex-direction: column;
  }
}
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [vue()],
}

Your production Vue interface now runs from an NGINX container. Next, you will add the Express API and expose the networking boundary between containers.

Expose the Localhost Trap

Your Vue interface is running from an NGINX container. The next slice connects that interface to real task data through a separate API.

Inside a container, localhost identifies that same container. You will deliberately give the Express API this database address so you can see why it cannot reach PostgreSQL.

In this step, get ready to:
  • Build an Express API with task routes and PostgreSQL access.
  • Connect the Vue interface to the API through NGINX.
  • Run the three-service stack and expose the deliberate database failure.
Build the Express API

The API gives the browser one place to load tasks and submit new ones. A node-postgres pool manages its database connections.

  • Create backend/package.json inside the Windows project workspace with this content:
{
  "name": "task-tracker-api",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.3.0",
    "pg": "8.23.1"
  }
}

What does this file define?

  • The start script launches server.js inside the API container.
  • Express 5.3.0 provides the HTTP server and routes.
  • The pg package at version 8.23.1 connects the API to PostgreSQL.
  • Save backend/package.json.
  • Confirm package.json appears inside the backend folder in the VS Code file sidebar.
  • Create backend/Dockerfile with this content:
FROM node:24.21.0-alpine3.24

WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

How is the API image built?

  • The pinned Node.js image supplies the API runtime.
  • The dependency install omits packages used only during development.
  • The container starts server.js and documents port 3000 as the API port.
  • Save backend/Dockerfile.
  • Confirm Dockerfile appears beside package.json in the backend folder.
  • Create backend/.dockerignore with this content:
node_modules

Why exclude this folder?

The API image installs its own dependencies. Excluding the Windows copy of node_modules keeps host-specific packages out of the Linux image.

  • Save backend/.dockerignore.
  • Confirm the backend folder now contains three files.

Missing a backend file?

Check that package.json, Dockerfile, and .dockerignore are directly inside backend.

Ask for help: Help me verify the locations and names of my backend container files.

The server needs connection settings before its routes can query data. These settings come from the API container environment in the Compose configuration.

  • Create backend/server.js with this first section:
import express from 'express'
import pg from 'pg'

const { Pool } = pg
const app = express()
const port = Number(process.env.PORT ?? 3000)

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 5432),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
})

app.use(express.json())

How does the server start?

  • The Pool reads its PostgreSQL connection details from the container environment.
  • The port value controls where Express accepts requests.
  • The JSON middleware makes submitted task data available through request.body.
  • Add the schema function below app.use(express.json()) in backend/server.js by pasting this code:
async function ensureSchema() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS tasks (
      id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
      title text NOT NULL,
      created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `)
}

What does the schema function create?

The ensureSchema() function creates the tasks table when it is missing. Each row receives an identity value and a creation timestamp.

  • Add the health route below ensureSchema() by pasting this code:
app.get('/health', async (request, response) => {
  try {
    await pool.query('SELECT 1 FROM tasks LIMIT 1')
    response.json({ status: 'ok' })
  } catch {
    response.status(503).json({ status: 'starting' })
  }
})

What does the health route prove?

The /health route queries the task table. A successful response proves that the API can reach the initialized database.

  • Add the task-loading route below the health route by pasting this code:
app.get('/api/tasks', async (request, response) => {
  try {
    const result = await pool.query(
      'SELECT id, title, created_at FROM tasks ORDER BY created_at DESC',
    )
    response.json(result.rows)
  } catch (error) {
    console.error('Task query failed:', error.message)
    response.status(500).json({ error: 'Database unavailable' })
  }
})

How are tasks loaded?

The GET route returns every task with the newest rows first. A database problem produces an error response that the Vue interface can detect.

  • Add the task-creation route below the task-loading route by pasting this code:
app.post('/api/tasks', async (request, response) => {
  const title = request.body.title?.trim()

  if (!title) {
    return response.status(400).json({ error: 'Task title is required' })
  }

  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING id, title, created_at',
      [title],
    )
    return response.status(201).json(result.rows[0])
  } catch (error) {
    console.error('Task insert failed:', error.message)
    return response.status(500).json({ error: 'Database unavailable' })
  }
})

How are tasks inserted safely?

The route rejects an empty title before querying PostgreSQL. The $1 placeholder keeps the submitted title separate from the SQL statement.

A successful insert returns the new database row. Vue can place that row at the top of the visible task list.

  • Add the server startup block below the task-creation route by pasting this code:
app.listen(port, async (error) => {
  if (error) throw error

  console.log(`API listening on port ${port}`)

  try {
    await ensureSchema()
    console.log('Database schema ready')
  } catch (databaseError) {
    console.error('Database initialization failed:', databaseError.message)
  }
})

What happens during startup?

Express begins listening before ensureSchema() checks the database. The catch block records a database initialization failure without stopping the API process.

  • Save backend/server.js.
  • Confirm the final line in the file closes the app.listen() call.

Seeing JavaScript warnings?

Check that each route closes with }). Confirm the schema query keeps its opening and closing backticks.

Ask for help: Help me compare my Express server structure with the required route order.

✔️ Awesome, I've got everything!

Your backend folder now has its package definition, image instructions, ignore rule, and Express server.

ⓧ I'd like to double check the full code

Compare each backend file with these complete references.

{
  "name": "task-tracker-api",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.3.0",
    "pg": "8.23.1"
  }
}

Package Reference

This file pins the API dependencies and defines its start script.

FROM node:24.21.0-alpine3.24

WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

Image Reference

This file installs the API dependencies and starts the server inside the Node.js image.

node_modules

Build Context Reference

This rule excludes host-installed dependencies from the image build context.

import express from 'express'
import pg from 'pg'

const { Pool } = pg
const app = express()
const port = Number(process.env.PORT ?? 3000)

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 5432),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
})

app.use(express.json())

async function ensureSchema() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS tasks (
      id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
      title text NOT NULL,
      created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `)
}

app.get('/health', async (request, response) => {
  try {
    await pool.query('SELECT 1 FROM tasks LIMIT 1')
    response.json({ status: 'ok' })
  } catch {
    response.status(503).json({ status: 'starting' })
  }
})

app.get('/api/tasks', async (request, response) => {
  try {
    const result = await pool.query(
      'SELECT id, title, created_at FROM tasks ORDER BY created_at DESC',
    )
    response.json(result.rows)
  } catch (error) {
    console.error('Task query failed:', error.message)
    response.status(500).json({ error: 'Database unavailable' })
  }
})

app.post('/api/tasks', async (request, response) => {
  const title = request.body.title?.trim()

  if (!title) {
    return response.status(400).json({ error: 'Task title is required' })
  }

  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING id, title, created_at',
      [title],
    )
    return response.status(201).json(result.rows[0])
  } catch (error) {
    console.error('Task insert failed:', error.message)
    return response.status(500).json({ error: 'Database unavailable' })
  }
})

app.listen(port, async (error) => {
  if (error) throw error

  console.log(`API listening on port ${port}`)

  try {
    await ensureSchema()
    console.log('Database schema ready')
  } catch (databaseError) {
    console.error('Database initialization failed:', databaseError.message)
  }
})

Server Reference

This complete server contains the database pool, schema initialization, health route, task routes, and startup logic.

Connect the browser to the API

The interface currently changes only its local state. It now needs to load tasks when the page mounts and send new titles to the API.

  • In frontend/src/App.vue, replace the existing content inside <script setup> with this first section:
import { onMounted, ref } from 'vue'

const tasks = ref([])
const newTask = ref('')
const loading = ref(true)
const error = ref('')

async function loadTasks() {
  loading.value = true
  error.value = ''

  try {
    const response = await fetch('/api/tasks')
    if (!response.ok) {
      throw new Error('Could not load tasks')
    }
    tasks.value = await response.json()
  } catch (errorValue) {
    error.value = errorValue.message
  } finally {
    loading.value = false
  }
}

How does task loading work?

The loadTasks() function requests /api/tasks and stores the returned rows in Vue state. A failed response becomes the visible Could not load tasks message.

  • Add this second section below loadTasks() inside the same script block:
async function addTask() {
  const title = newTask.value.trim()
  if (!title) return

  error.value = ''

  try {
    const response = await fetch('/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    })

    if (!response.ok) {
      throw new Error('Could not add task')
    }

    const task = await response.json()
    tasks.value.unshift(task)
    newTask.value = ''
  } catch (errorValue) {
    error.value = errorValue.message
  }
}

onMounted(loadTasks)

How does the interface use the API?

The addTask() function sends the title as JSON. A successful response supplies the stored task row that Vue adds to the list.

The onMounted(loadTasks) call starts the first request when the interface mounts.

  • Save frontend/src/App.vue.
  • Confirm the script ends with onMounted(loadTasks) before the closing script tag.

NGINX already serves the Vue bundle. A proxy location lets it forward browser requests beginning with /api/ to the API service.

  • Replace the contents of frontend/nginx.conf with this configuration:
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://api:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

How does the proxy connect the services?

  • The /api/ location forwards API requests to the Compose service named api on port 3000.
  • The proxy target has no URI component. NGINX therefore preserves paths such as /api/tasks for Express.
  • The final location keeps the existing single-page application fallback.
  • Save frontend/nginx.conf.
  • Confirm the file contains the API location above the existing application location.

Proxy block in the wrong place?

Keep both location blocks inside the single server block. Confirm the API location closes before the application location begins.

Ask for help: Help me check the structure of my NGINX server and API proxy blocks.

✔️ Awesome, I've got everything!

The Vue interface now calls the API through the NGINX proxy.

ⓧ I'd like to double check the full code

Compare your updated frontend files with these complete references.

<script setup>
import { onMounted, ref } from 'vue'

const tasks = ref([])
const newTask = ref('')
const loading = ref(true)
const error = ref('')

async function loadTasks() {
  loading.value = true
  error.value = ''

  try {
    const response = await fetch('/api/tasks')
    if (!response.ok) {
      throw new Error('Could not load tasks')
    }
    tasks.value = await response.json()
  } catch (errorValue) {
    error.value = errorValue.message
  } finally {
    loading.value = false
  }
}

async function addTask() {
  const title = newTask.value.trim()
  if (!title) return

  error.value = ''

  try {
    const response = await fetch('/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    })

    if (!response.ok) {
      throw new Error('Could not add task')
    }

    const task = await response.json()
    tasks.value.unshift(task)
    newTask.value = ''
  } catch (errorValue) {
    error.value = errorValue.message
  }
}

onMounted(loadTasks)
</script>

<template>
  <main class="shell">
    <section class="card">
      <p class="eyebrow">Docker Compose Lab</p>
      <h1>Task Tracker</h1>
      <p class="subtitle">Vue, Express, and PostgreSQL working as separate containers.</p>

      <form class="task-form" @submit.prevent="addTask">
        <input v-model="newTask" aria-label="Task title" placeholder="Add a task" />
        <button type="submit">Add task</button>
      </form>

      <p v-if="error" class="message error">{{ error }}</p>
      <p v-else-if="loading" class="message">Loading tasks...</p>
      <p v-else-if="tasks.length === 0" class="message">No tasks yet. Add the first one.</p>

      <ul v-else class="task-list">
        <li v-for="task in tasks" :key="task.id">
          <span>{{ task.title }}</span>
          <small>{{ new Date(task.created_at).toLocaleString() }}</small>
        </li>
      </ul>
    </section>
  </main>
</template>

Interface Reference

This complete component loads tasks on mount and submits new tasks through the API.

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://api:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Proxy Reference

This complete configuration serves the Vue bundle and forwards API paths to Express.

Run the deliberate localhost failure

A Docker Compose network gives each service its own network identity. The database service will be healthy, but the API will deliberately search for PostgreSQL inside its own container.

  • Replace compose.yaml with this database service:
services:
  db:
    image: postgres:18.6-alpine3.24
    environment:
      POSTGRES_DB: tasktracker
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: local-dev-password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

How does the database become ready?

The PostgreSQL image creates the local database and user from its environment. The health check waits for that database to accept connections.

  • Save compose.yaml.
  • Validate the database-only Compose model by running this command in the PowerShell terminal from earlier:
docker compose config

What does this check prove?

Compose parses the current file and renders the resolved model. A rendered db service confirms that the first configuration section is valid.

  • Add the API service below the database service by pasting this configuration:
  api:
    build:
      context: ./backend
    environment:
      PORT: 3000
      DB_HOST: localhost
      DB_PORT: 5432
      DB_NAME: tasktracker
      DB_USER: taskuser
      DB_PASSWORD: local-dev-password
    depends_on:
      db:
        condition: service_healthy

Where is the deliberate mistake?

The API waits for the database health check before it starts. Its DB_HOST value still points to localhost, which identifies the API container itself.

  • Add the frontend service below the API service by pasting this configuration:
  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - api

How does the frontend join the stack?

The frontend keeps its Windows port mapping and starts after the API container starts. All three services share the default Compose network.

  • Save compose.yaml.
  • Validate the complete three-service model by running:
docker compose config

What should the model contain?

The rendered model should contain db, api, and frontend services. The API dependency should use the database health condition.

Compose configuration invalid?

Check that all three service names align beneath services. Confirm the API environment values align beneath environment.

Ask for help: Help me find the YAML indentation problem in my three-service Compose file.

✔️ Awesome, I've got everything!

Your Compose model now defines the database, API, and frontend services.

ⓧ I'd like to double check the full code

Compare your complete Compose file with this reference.

services:
  db:
    image: postgres:18.6-alpine3.24
    environment:
      POSTGRES_DB: tasktracker
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: local-dev-password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  api:
    build:
      context: ./backend
    environment:
      PORT: 3000
      DB_HOST: localhost
      DB_PORT: 5432
      DB_NAME: tasktracker
      DB_USER: taskuser
      DB_PASSWORD: local-dev-password
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - api

Compose Reference

This complete file health-gates the API on PostgreSQL and preserves the deliberate localhost database address.

The first build can take a few minutes while Docker downloads the pinned images and installs both sets of JavaScript packages.

Before you start the stack, do you think the database health check is enough for the API to connect successfully?

  • Build and start the three-service stack by running:
docker compose up --build --detach

What does this command do?

Compose builds the API and frontend images before starting the services in the background. It waits for the database health check before creating the API container.

  • Return to http://localhost:8080 in your browser.
  • Refresh the Task Tracker page.

You will see Could not load tasks beneath the form. The interface reached the API path, but the API could not complete its database work.

This Failure Is Intentional

The API container searches itself when it uses localhost as the database host. PostgreSQL is running in the separate db container.

You have made container isolation visible. The browser error is evidence of the networking gap this step set out to expose.

Before you inspect the logs, which startup stage do you expect to report the failed database connection?

  • Inspect the API service logs by running:
docker compose logs api

What do the logs prove?

The output includes Database initialization failed. This confirms that Express started but could not initialize its PostgreSQL schema through localhost.

That is the intended result. The API is isolated from the database until it uses the database service name.

Seeing a different result?

If the browser cannot load the page at all, confirm the frontend container is running and that Windows port 8080 is available.

If the API logs do not show the initialization failure, confirm DB_HOST still has the deliberate value localhost.

Ask for help: Help me diagnose why I am not seeing the intended localhost database failure.

You have reproduced the container networking trap and traced it from the browser to the API logs. Next, you will replace the loopback address with Compose service-name discovery.

Connect Services by Name

Your three-service Docker Compose stack is running. Its failure has exposed exactly where the API is looking for the database.

Inside the API container, localhost points back to that container. Docker's internal DNS gives the PostgreSQL service the hostname db.

Once the connection works, you'll recreate the stack to test what happens to the task you add.

In this step, get ready to:
  • Replace the API's local database hostname with the Compose service name.
  • Submit a task through the working three-service stack.
  • Recreate the containers to expose the database storage gap.
Fix database discovery

Compose service names work as hostnames on the stack's default network. The API can therefore reach the database through db on port 5432.

  • In Visual Studio Code, select compose.yaml from the Explorer sidebar.
  • Find this line under the API's environment block:
      DB_HOST: localhost

Why Does Localhost Fail Here?

This value tells the API to search its own container for PostgreSQL. The database runs in the separate db service.

  • Replace localhost with db so the line looks like this:
      DB_HOST: db

What Changes Here?

The hostname now matches the Compose service name. Docker routes requests from the API container to the database container through its internal network.

PostgreSQL remains private to the stack because the db service has no ports mapping.

  • Save compose.yaml.
  • Validate the updated Compose model in the PowerShell terminal from earlier by running:
docker compose config

What Does This Check Do?

This command parses the Compose file. It resolves the configuration without starting new containers.

In the rendered API environment, you should see DB_HOST: db.

Still Seeing the Old Hostname?

  • Confirm that you saved compose.yaml after changing the hostname.
  • Align DB_HOST with the neighboring environment keys if validation reports a YAML indentation problem.

Ask for help: Help me check why Docker Compose still renders DB_HOST as localhost.

✔️ Awesome, I've got everything!

Your Compose configuration now points the API at the database service. Make sure compose.yaml is saved before continuing.

ⓧ I'd like to double check the full code

Compare your complete compose.yaml file with this version.

services:
  db:
    image: postgres:18.6-alpine3.24
    environment:
      POSTGRES_DB: tasktracker
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: local-dev-password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  api:
    build:
      context: ./backend
    environment:
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: tasktracker
      DB_USER: taskuser
      DB_PASSWORD: local-dev-password
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - api

What Should Match?

The API environment should contain DB_HOST: db with DB_PORT: 5432. The db service should have no host port mapping.

Validate the working stack

The corrected hostname gives the API a route to PostgreSQL. A browser submission now tests the entire request path through NGINX before reaching the database.

Before you rebuild the stack, do you think the API will still report a database initialization failure?

  • Rebuild the images and start the corrected stack by running:
docker compose up --build --detach

What Happens During Startup?

Compose rebuilds the application images before starting the services in the background. The API waits for the PostgreSQL health check before it starts.

  • Switch back to your browser.
  • Select the address bar.
  • Enter http://localhost:8080.
  • Press Enter.
  • Enter Connect services by name in the task field.
  • Click Add task.

You'll see Connect services by name in the task list without the error banner. That connection is live across all three services.

How Did the Task Reach PostgreSQL?

  • NGINX forwards the browser's /api/tasks request to the API service.
  • Express inserts the title through the parameterized $1 query before returning the new task.

Still Seeing the Error Banner?

  • Confirm that compose.yaml contains DB_HOST: db.
  • Inspect the API logs with docker compose logs api if the browser still displays Could not load tasks.

Ask for help: Help me diagnose why my API still cannot reach the db service.

Expose the storage gap

The submitted task proves that service-name networking works. Recreating the containers now tests whether the database storage follows the new database container.

Before you recreate the stack, do you think Connect services by name will still appear after the page reloads?

  • Remove the current containers before recreating the stack by running these commands:
docker compose down
docker compose up --build --detach

What Do These Commands Change?

The first command removes the current Compose containers. The second command rebuilds the images before creating a fresh set of containers.

  • Refresh the task tracker page with Ctrl+R.

You'll see No tasks yet. Add the first one. The task you submitted is missing.

Why Did the Task Disappear?

PostgreSQL stored the task in an anonymous volume associated with the previous database container. Compose leaves that storage behind during docker compose down.

The next docker compose up --build --detach creates fresh anonymous storage. The recreated database therefore initializes an empty tasks table.

You proved that Compose service names connect isolated containers. Next, you'll give PostgreSQL storage a stable lifecycle so task data survives container replacement.

Persist PostgreSQL Data

The Express API now reaches PostgreSQL through Docker's internal DNS. The last container recreation exposed the storage gap because your earlier task disappeared.

A named volume gives the database files a stable lifecycle outside any one container. You will attach db-data to the database so the Docker Compose stack can preserve tasks through another teardown.

In this step, get ready to:
  • Declare named db-data storage for PostgreSQL.
  • Add a distinct Prove persistence task through the browser.
  • Confirm the same task remains after container recreation.
Declare stable database storage

Compose creates a declared named volume once. Later starts reuse that volume. Mounting it at /var/lib/postgresql places the database files outside disposable container storage.

  • Switch back to Visual Studio Code.
  • Select compose.yaml in the Explorer sidebar.
  • Find the environment: block inside the db service.
  • Insert this volume mount directly below the POSTGRES_PASSWORD line:
    volumes:
      - db-data:/var/lib/postgresql

What Does This Mount Do?

  • The db-data value identifies the named storage source.
  • The /var/lib/postgresql value identifies the database storage path inside the PostgreSQL container.
  • Scroll to the end of compose.yaml after the frontend service.
  • Add this top-level volume declaration:
volumes:
  db-data:

What Does This Declaration Do?

The top-level volumes key makes db-data a project-managed named volume. Compose can now create it once and reuse it on future starts.

  • Save compose.yaml.

Before you validate the file, do you expect Compose to recognize both the database mount and its top-level declaration?

  • Validate the updated Compose model by running this command in the PowerShell terminal from earlier:
docker compose config

What Does This Check Do?

The command parses the Compose file and renders the resolved configuration. A successful result confirms that the new YAML structure is valid.

You should see a rendered db service with db-data mounted at /var/lib/postgresql. You should also see db-data in the top-level volume configuration.

Seeing a Compose Validation Error?

  • Check that the volumes: mount under db lines up with environment: and healthcheck:.
  • Check that the final top-level volumes: key begins at the far left. Indenting it under frontend changes its meaning.

Ask for help: Help me fix my Compose volume configuration.

✔️ Awesome, I've got everything!

Your Compose configuration now declares and mounts the named volume. Keep compose.yaml saved before recreating the stack.

ⓧ I'd like to double check the full code

Compare your complete compose.yaml file with this target configuration.

services:
  db:
    image: postgres:18.6-alpine3.24
    environment:
      POSTGRES_DB: tasktracker
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: local-dev-password
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  api:
    build:
      context: ./backend
    environment:
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: tasktracker
      DB_USER: taskuser
      DB_PASSWORD: local-dev-password
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - api

volumes:
  db-data:
Recreate the stack with the named volume

The updated stack needs a fresh database container that mounts db-data. Compose creates the named volume automatically when it does not already exist.

  • Rebuild the images and recreate the stack by running:
docker compose up --build --detach

What Does This Command Do?

The command rebuilds any changed images. Compose also creates the declared volume before starting the database container.

The terminal should show the services being created or started. Keep the terminal open until the command returns to the PowerShell prompt.

Did the Stack Fail to Start?

  • Confirm Docker Desktop is still running before retrying the command.
  • Check the rendered configuration again if the failure mentions the volume or the Compose file.

Ask for help: Help me diagnose why my Compose stack did not start after adding the named volume.

  • Return to the browser tab at http://localhost:8080.
  • Refresh the task tracker.
  • Enter Prove persistence in the Task title field.
  • Click Add task.

You should see Prove persistence in the task list with no error banner.

That gives you a clear test record. Its survival after the next recreation proves the volume works.

Task Missing From the List?

  • Confirm the page does not show a database error banner.
  • Refresh the browser after the database health check completes.
  • Check that the task title is not still sitting in the input field.

Ask for help: Help me find why the task was not added to PostgreSQL.

Prove the task survives recreation

A persistence test only counts when the database container is replaced. The named volume must carry the task into the replacement container.

Before you recreate the stack, do you think Prove persistence will still appear after the browser reconnects?

  • Remove the current containers and recreate the stack by running these commands:
docker compose down
docker compose up --build --detach

What Happens to the Volume?

The first command removes the project containers and network. The declared named volume remains available.

The second command creates replacement containers. The database service remounts the existing db-data volume.

You should see the existing services stop first. You should then see the three services start again.

  • Return to the task tracker browser tab.
  • Refresh the page.

You should still see Prove persistence in the task list. Its continued presence proves PostgreSQL reused the named volume.

You have closed the storage gap. Your task tracker now preserves database rows when its containers are replaced.

Did the Task Disappear?

  • Confirm compose.yaml still contains the db-data:/var/lib/postgresql mount under db.
  • Confirm the top-level volumes: declaration still contains db-data:.
  • Confirm you added Prove persistence after the stack started using the named volume.

Ask for help: Help me debug why my PostgreSQL task disappeared after container recreation.

Secret mission

Gate the Frontend on API Health

Your database already has a readiness gate. The frontend can still start before the API is ready. Add an API health check so Docker Compose waits for genuine API readiness before starting the frontend.

Clean Up Your Resources

Clean Up Your Resources

Your task tracker runs locally through Docker Compose at $0 for personal learning use. Choose whether to keep it running, pause it for later, or delete its local resources.

Resources you used:

  • Three Compose service containers for db, api, and frontend.
  • One Compose network that connects the three service containers.
  • One persistent db-data named volume containing your PostgreSQL task data.
  • Your current Windows project workspace containing compose.yaml.

Keep everything running

No action is needed. Choose this option if you want to keep using the task tracker or experiment with its containers.

  • Leave the stack running in Docker Desktop.
  • Use http://localhost:8080 to continue adding tasks.
  • Keep your current Windows project workspace for future changes.

Pause - I'll come back to this later

Shut down the running containers to free local memory. Your project files and saved tasks stay available for your next session.

  • Switch back to the PowerShell terminal in your current Windows project workspace.
  • Stop the Compose stack by running this command:
docker compose down

What Does This Preserve?

The command removes the three containers and their Compose network. The db-data named volume stays available for your next launch.

Your current Windows project workspace also remains untouched.

That is your clean pause point. The application stops using memory while db-data keeps every saved task.

Delete - I don't want to use this again

Removing db-data permanently deletes every saved task. Your application files remain until you delete the current Windows project workspace.

  • Switch back to the PowerShell terminal in your current Windows project workspace.
  • Remove the containers, Compose network, and named volume by running this command:
docker compose down --volumes

What Gets Deleted?

The --volumes option removes named volumes declared in compose.yaml. This includes db-data and all tasks stored in PostgreSQL.

Compose also removes the three containers and their network. The two application images and the PostgreSQL image remain in Docker's local image cache.

Once PowerShell returns to its prompt, the running stack and persistent task data are gone. Your cached images are inactive local files.

  • Close Visual Studio Code to release files in the workspace.
  • Delete the current Windows project workspace using your preferred Windows file manager.

The workspace should no longer appear in its parent folder. Your local project cleanup is complete.

Having Trouble Cleaning Up?

Compose needs Docker Desktop to be running while it removes the local resources. Return to Docker Desktop if it has stopped before retrying the cleanup.

Help me troubleshoot local Compose cleanup.

Nice Work!

Nice Work!

Outstanding work. Your task tracker now runs as a three-service Docker Compose application at http://localhost:8080.

You've learned how to:

  • Built a production Vue interface with a multi-stage build. Used Node.js to compile the app. Configured NGINX to serve the final bundle. Forwarded /api requests through a reverse proxy.
  • Containerized an Express API that reads tasks from PostgreSQL. Added parameterized inserts for new tasks. Diagnosed why localhost points back to the current container. Connected the API to db through internal DNS.
  • Protected database startup with a PostgreSQL health check. Stored tasks in a named volume that survives container recreation.
  • Secret Mission: Added an API health check. Gated the frontend until the API reports a healthy status.

Ready to quiz yourself?