← All articles

A three-tier application with Docker Compose and Flyway

A three-tier application is easy to draw and surprisingly easy to flatten into one permissive network. The browser-facing tier, application tier, and data tier may run in separate containers, but if every container joins Docker Compose's default network, every service can still address every other service. The diagram looks layered; the network is not.

This walkthrough builds a small, runnable three-tier application with a static Nginx frontend, a Node.js API, PostgreSQL, and a one-shot Flyway migration. It uses two user-defined networks: the frontend and backend share one network, while the backend and database share a different, internal network. The backend is the only application container attached to both.

The target architecture

Browser
   |
   | localhost:8080
   v
+----------+        edge network        +----------+
| frontend | <-------------------------> | backend  |
|  Nginx   |                             | Node API |
+----------+                             +----------+
                                                  |
                                data network      |
                                  (internal)      |
                                                  v
                                   +----------+   +------------+
                                   |  Flyway  |-->| PostgreSQL |
                                   | one-shot |   |     db     |
                                   +----------+   +------------+

The frontend publishes port 8080 to the host. The backend uses expose, which documents its container port without publishing it to the host. PostgreSQL has no ports entry at all. Nginx proxies browser requests from /api to backend:3000 over the edge network, and the API reaches db:5432 over the data network.

This is a useful boundary, not a complete security system. Network separation reduces unintended reachability; it does not replace authentication, least-privilege database roles, secret management, TLS, image scanning, or host-level controls.

Project layout

three-tier-demo/
├── compose.yaml
├── .env
├── frontend/
│   ├── Dockerfile
│   ├── nginx.conf
│   └── index.html
├── backend/
│   ├── Dockerfile
│   ├── package.json
│   ├── package-lock.json
│   └── server.js
└── db/
    └── migrations/
        └── V1__create_messages.sql

The example is intentionally small, but the boundaries are the same ones we use in larger systems: only the ingress tier publishes a port, service names provide internal DNS, schema changes run before the API starts, and persistent data lives in a named volume.

1. Build the frontend image

The frontend is plain HTML served by Nginx. Its Dockerfile also replaces the default Nginx virtual host with a reverse-proxy configuration.

frontend/Dockerfile:

FROM nginx:1.31-alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html

EXPOSE 80

frontend/nginx.conf:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

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

The hostname backend is not public DNS. Docker's embedded DNS resolves the Compose service name because Nginx and the API share the edge network. The browser never needs to resolve that name; it calls the same origin at /api/messages.

frontend/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Three-tier message board</title>
  <style>
    body { max-width: 42rem; margin: 4rem auto; padding: 0 1rem;
           font: 16px/1.5 system-ui, sans-serif; }
    form { display: flex; gap: .5rem; }
    input { flex: 1; padding: .65rem; }
    button { padding: .65rem 1rem; }
  </style>
</head>
<body>
  <h1>Three-tier message board</h1>
  <form id="message-form">
    <input id="message" maxlength="200" required
           placeholder="Write a message">
    <button>Save</button>
  </form>
  <ul id="messages"></ul>

  <script>
    const list = document.querySelector('#messages');
    const form = document.querySelector('#message-form');
    const input = document.querySelector('#message');

    async function loadMessages() {
      const response = await fetch('/api/messages');
      if (!response.ok) throw new Error('Could not load messages');
      const messages = await response.json();

      list.replaceChildren(...messages.map((message) => {
        const item = document.createElement('li');
        item.textContent = message.body;
        return item;
      }));
    }

    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      const response = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ body: input.value })
      });
      if (!response.ok) throw new Error('Could not save message');
      input.value = '';
      await loadMessages();
    });

    loadMessages().catch(console.error);
  </script>
</body>
</html>

2. Build the backend image

The API exposes a health endpoint plus two message endpoints. It gets all database connection settings from the environment and never publishes its port to the host.

backend/package.json:

{
  "name": "three-tier-backend",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "pg": "^8.16.3"
  }
}

Create and commit the lock file before building:

cd backend
npm install --package-lock-only
cd ..

backend/Dockerfile:

FROM node:24-alpine AS dependencies

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24-alpine

ENV NODE_ENV=production
WORKDIR /app

COPY --from=dependencies /app/node_modules ./node_modules
COPY --chown=node:node server.js ./server.js

USER node
EXPOSE 3000
CMD ["node", "server.js"]

The final stage contains only the production dependency tree and application file, and the process runs as the image's unprivileged node user.

backend/server.js:

const http = require('node:http');
const { Pool } = require('pg');

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
});

function send(response, status, payload) {
  response.writeHead(status, { 'content-type': 'application/json' });
  response.end(JSON.stringify(payload));
}

async function readJson(request) {
  let raw = '';
  for await (const chunk of request) {
    raw += chunk;
    if (raw.length > 10_000) throw new Error('Request is too large');
  }
  return JSON.parse(raw || '{}');
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method === 'GET' && request.url === '/health') {
      await pool.query('SELECT 1');
      return send(response, 200, { status: 'ok' });
    }

    if (request.method === 'GET' && request.url === '/api/messages') {
      const result = await pool.query(
        'SELECT id, body, created_at FROM messages ORDER BY id DESC LIMIT 50'
      );
      return send(response, 200, result.rows);
    }

    if (request.method === 'POST' && request.url === '/api/messages') {
      const input = await readJson(request);
      const body = typeof input.body === 'string' ? input.body.trim() : '';

      if (!body || body.length > 200) {
        return send(response, 400, { error: 'body must be 1-200 characters' });
      }

      const result = await pool.query(
        'INSERT INTO messages (body) VALUES ($1) RETURNING id, body, created_at',
        [body]
      );
      return send(response, 201, result.rows[0]);
    }

    return send(response, 404, { error: 'not found' });
  } catch (error) {
    console.error(error);
    return send(response, 500, { error: 'internal server error' });
  }
});

server.listen(3000, '0.0.0.0', () => {
  console.log('API listening on port 3000');
});

async function shutdown() {
  server.close(async () => {
    await pool.end();
    process.exit(0);
  });
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

3. Add the first Flyway migration

Flyway treats migrations as an ordered history. Versioned files follow the pattern V<version>__<description>.sql, with two underscores between the version and description. Our first migration creates the only table this application needs.

db/migrations/V1__create_messages.sql:

CREATE TABLE messages (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    body VARCHAR(200) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO messages (body)
VALUES ('Hello from Flyway');

When Flyway runs, it also creates flyway_schema_history. That table records the version, description, checksum, execution time, and outcome of every migration. On the next startup Flyway sees that V1 already succeeded and does not run it again. To change the schema later, add V2__add_message_author.sql; do not edit an already-applied migration.

4. Connect the tiers with Docker Compose

The Compose file is where the architectural boundary becomes enforceable. Every service declares its networks explicitly, so Compose does not quietly attach it to the implicit default network.

compose.yaml:

services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "8080:80"
    depends_on:
      backend:
        condition: service_started
    networks:
      - edge
    restart: unless-stopped

  backend:
    build:
      context: ./backend
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_NAME: ${POSTGRES_DB:-appdb}
      DB_USER: ${POSTGRES_USER:-app}
      DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    expose:
      - "3000"
    depends_on:
      flyway:
        condition: service_completed_successfully
    networks:
      - edge
      - data
    restart: unless-stopped

  flyway:
    image: flyway/flyway:12-alpine
    command:
      - -url=jdbc:postgresql://db:5432/${POSTGRES_DB:-appdb}
      - -user=${POSTGRES_USER:-app}
      - -password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      - -locations=filesystem:/flyway/sql
      - -connectRetries=60
      - migrate
    volumes:
      - ./db/migrations:/flyway/sql:ro
    depends_on:
      db:
        condition: service_healthy
    networks:
      - data
    restart: "no"

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-appdb}
      POSTGRES_USER: ${POSTGRES_USER:-app}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s
    volumes:
      - postgres-data:/var/lib/postgresql
    networks:
      - data
    restart: unless-stopped

networks:
  edge:
    driver: bridge
  data:
    driver: bridge
    internal: true

volumes:
  postgres-data:

The two long-form depends_on conditions do different jobs:

  • service_healthy waits for PostgreSQL's health check before starting Flyway.
  • service_completed_successfully starts the backend only after the one-shot migration container exits with status zero.

This prevents a common startup race: starting the API after the database process exists but before PostgreSQL accepts connections or before the expected tables exist. The -connectRetries=60 option adds resilience if the database briefly becomes unavailable between the health check and the JDBC connection.

The data network has internal: true, which creates an externally isolated network. The database and Flyway job attach only to it. The backend joins both edge and data, making it the deliberate bridge between HTTP traffic and stored data. Docker does not turn the backend into an IP router between those networks; it communicates through application-level connections.

The PostgreSQL 18 image stores its version-specific PGDATA below /var/lib/postgresql, so that is where the named volume is mounted. PostgreSQL 17 and older images use /var/lib/postgresql/data; changing the image major version without checking this path can leave the real data in an anonymous volume.

5. Configure and run the stack

For a local demonstration, put the database settings in .env next to compose.yaml:

POSTGRES_DB=appdb
POSTGRES_USER=app
POSTGRES_PASSWORD=replace-this-local-password

Do not commit that file. Commit a .env.example containing placeholders, add .env to .gitignore, and use Docker secrets or an external secret manager outside a local demo.

Validate the resolved Compose model, build the two application images, and start the stack:

docker compose config
docker compose up --build

Open http://localhost:8080. The request path is:

browser -> localhost:8080 -> frontend -> backend -> db

You can also test through the published frontend port:

curl http://localhost:8080/api/messages

curl -X POST http://localhost:8080/api/messages \
  -H 'content-type: application/json' \
  -d '{"body":"The network boundary works"}'

Inspect Flyway's completed job and migration history:

docker compose logs flyway

docker compose exec db psql -U app -d appdb \
  -c 'SELECT installed_rank, version, description, success
      FROM flyway_schema_history ORDER BY installed_rank;'

Notice what is absent from docker compose ps: neither 3000 nor 5432 is published on the host. A request to localhost:3000 should fail, as should one to localhost:5432. Those ports are reachable only from containers on the appropriate shared network.

Adding a second migration

Create db/migrations/V2__add_message_author.sql:

ALTER TABLE messages
ADD COLUMN author VARCHAR(100);

UPDATE messages
SET author = 'anonymous'
WHERE author IS NULL;

ALTER TABLE messages
ALTER COLUMN author SET NOT NULL;

Then run:

docker compose run --rm flyway

Flyway reads the existing schema history, skips V1, applies V2, and records its checksum. In an automated delivery pipeline, run migrations as a distinct release step and stop the deployment if Flyway exits unsuccessfully.

What to change before production

This example gets the topology and migration lifecycle right, but production needs a few stronger controls:

  • Pin every base image and service image to an immutable digest, then update it through a tested dependency process.
  • Give Flyway a schema-owner account and give the running API a separate account with only the DML privileges it needs. The demo shares one account to stay readable.
  • Move credentials out of .env and into Docker secrets or your platform's secret manager.
  • Add TLS at the ingress, request limits, structured logs, metrics, backups, restore tests, and resource limits.
  • Build images in CI, scan them, and deploy immutable artifacts instead of compiling source on the production host.
  • Design migrations for mixed-version deployments. A safe sequence often adds a compatible schema first, deploys application code second, and removes obsolete columns only after old code is gone.

The key idea

Three containers do not automatically create three tiers. The meaningful architecture comes from explicit reachability:

  • The frontend can reach the backend, but it cannot reach the database.
  • The database can be reached by the backend and the migration job, but it is not published to the host.
  • The backend is the only long-running service that belongs to both networks.
  • Flyway makes database state a versioned, repeatable prerequisite of application startup.

Docker Compose is not a production orchestrator, but it is an excellent way to make these boundaries visible, executable, and testable before translating the same design into Kubernetes network policies, cloud security groups, or another runtime.

For the underlying behavior, see Docker's documentation for Compose networks and startup order, Redgate's Flyway Docker guide, and the PostgreSQL Official Image documentation.