کدنامهمرجع‌های مهندسی نرم‌افزار، به فارسی
Docker · پروژهٔ ۲Docker · Project 2

پروژهٔ ۲ — اپ + پایگاه‌داده با Compose

Project 2 — Web application + database with Docker Compose

یک برنامهٔ کوچک حالا به PostgreSQL نیاز دارد. هم‌تیمی‌ها باید با یک فرمان کل محیط را بالا بیاورند، داده پس از جایگزینی container بماند و port پایگاه‌داده برای میزبان منتشر نشود. تو باید این قرارداد را بسازی و با مدرک تحویل بدهی.

A small application now needs PostgreSQL. Teammates should be able to start the whole environment with one command, data must survive container replacement, and the database port must not be published to the host. Build that contract and hand it off with evidence.

2serviceservices
0port عمومی DBpublished DB ports
≈ 4hزمان پیشنهادیsuggested time

مأموریت دوم: دو سرویس را طوری کنار هم بگذار که داده با تعویض web از بین نرودOne command, two services, one question about data

تا دیروز API را تنها اجرا می‌کردیم. حالا یادداشت‌ها باید در PostgreSQL بمانند. از این لحظه «روی لپ‌تاپ من کار می‌کند» کافی نیست: هم‌تیمی باید با یک فرمان web و پایگاه‌داده را بالا بیاورد، تعویض web نباید noteها را پاک کند و port پایگاه‌داده هم نباید برای بیرون باز باشد.

Until yesterday, the team ran its API by itself. Now notes must be stored in PostgreSQL. “It works on my laptop” is no longer enough: a teammate needs one command to start web and the database; replacing web must not erase notes; and nobody outside the app network should connect directly to the database port.

The host reaches only web; web resolves postgres on the Compose network; PostgreSQL stores data in a named volume. Compose network · private service communication Host127.0.0.1:8080 web serviceDB_HOST=postgresonly published service postgres service5432 · internal onlyno host port named volume · pgdata service-name DNS

این پروژه مرور فصل Compose نیست. قرار است چیزهایی که قبلاً جدا یاد گرفته‌ای کنار هم قرار بگیرند: service-name DNS، volume، secret فایل‌محور، healthcheck، startup dependency و عیب‌یابی. معیار موفقیت هم فقط بالا آمدن دو container نیست؛ باید persistence و ارتباط داخلی را با مدرک ثابت کنی.

The host arrow reaches only web; the internal network arrow uses the service name postgres; the arrow to the volume means PostgreSQL data outlives its container.

این صفحه tutorial تازه‌ای دربارهٔ Compose نیست. مدل چند service، DNS داخلی، volume، secret و healthcheck را از فصل‌های قبل می‌دانی. پروژه از تو می‌خواهد این قراردادها را کنار هم قرار دهی، بعد خرابشان کنی و از روی شواهد تشخیص بدهی.

This is not another Compose tutorial. You already know multi-service models, internal DNS, volumes, secrets, and healthchecks from earlier chapters. Your job is to combine those contracts, deliberately break them, and diagnose the evidence.

مرز پروژه را روشن نگه دار: دو service، یک Compose و داده‌ای که باید بماندScope and deliverables

  • دو service با نقش روشن: web و PostgreSQL.
  • یک compose.yaml؛ web از Dockerfile محلی build می‌شود.
  • Compose-managed network؛ web مقصد DB را با service name postgres پیدا می‌کند.
  • named volume برای دادهٔ PostgreSQL؛ بدون port میزبان برای DB.
  • تنظیمات غیرحساس در environment؛ رمز از secret فایل‌محور فصل ۹.
  • healthcheck برای هر دو service؛ web فقط پس از healthy شدن DB در شروع بالا بیاید.
  • web با user غیرریشه، restart policy و سقف‌های معقول اجرا شود.
  • log هر دو service با Compose خوانده شود؛ پشتیبان محلی تولید و با ابزار PostgreSQL بازرسی شود.
  • Two clearly named service roles: web and PostgreSQL.
  • One compose.yaml; web is built from a local Dockerfile.
  • A Compose-managed network; web finds the database using the service name postgres.
  • A named volume for PostgreSQL data, with no host-published DB port.
  • Non-secret configuration in environment variables; the password uses Chapter 9’s file-backed secret pattern.
  • Healthchecks for both services; web waits for a healthy DB during initial startup.
  • Web runs as non-root with a restart policy and reasonable limits.
  • Read logs for both services through Compose; create a local backup and inspect it with PostgreSQL tooling.
سه «آماده‌بودن» را قاطی نکنDo not conflate three kinds of readiness

Compose ترتیب شروع را هماهنگ می‌کند، healthcheck می‌گوید آزمون سلامت پاس شده و برنامه باید قطع‌شدن پایگاه‌داده در زمان اجرا را هم خودش مدیریت کند. service_healthy می‌تواند race اولیه را کمتر کند، اما monitor دائمی ارتباط یا درمان خودکار خطا نیست.

Compose orders startup; a healthcheck reports whether its probe succeeds; and the app still needs to handle database loss while running. service_healthy prevents an initial startup race, but it is not a permanent monitor or automatic connection repair.

منطق برنامه را کوچک نگه دار تا مسئلهٔ اصلی Compose و persistence بماندStarter: keep the application logic small

API فقط چند مسیر سرویس ساده دارد: معرفی سرویس، health، ساخت note و فهرست noteها. PostgreSQL تنها منبع ماندگار داده است. عمداً cache پیچیده، queue یا فایل محلی اضافه نمی‌کنیم تا وقتی note گم شد دقیقاً بدانیم دنبال کدام مسیر باید بگردیم.

The API does only four things: identify the service, check connectivity at /health, create notes with POST /notes, and list them with GET /notes. PostgreSQL is the only durable data store; there is no in-memory array or writable-layer file pretending to persist.

web/package.json
{
  "name": "compose-notes-web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "pg": "^8.13.1"
  }
}
web/server.js · کد آغازین برنامهstarter application
const http = require('node:http');
const fs = require('node:fs');
const { Pool } = require('pg');

const port = Number(process.env.PORT || 3000);
const mode = process.env.APP_MODE || 'development';
const dbHost = process.env.DB_HOST || 'postgres';
const passwordFile = process.env.DB_PASSWORD_FILE;
const password = passwordFile ? fs.readFileSync(passwordFile, 'utf8').trim() : '';

const pool = new Pool({
  host: dbHost,
  port: Number(process.env.DB_PORT || 5432),
  database: process.env.DB_NAME || 'notes',
  user: process.env.DB_USER || 'notes_app',
  password,
  connectionTimeoutMillis: 1500
});

const send = (response, status, value) => {
  response.writeHead(status, { 'content-type': 'application/json' });
  response.end(JSON.stringify(value));
};

async function handle(request, response) {
  const path = (request.url || '/').split('?')[0];

  if (request.method === 'GET' && path === '/') {
    return send(response, 200, { app: 'compose-notes', mode, dbHost });
  }

  if (request.method === 'GET' && path === '/health') {
    try {
      await pool.query('SELECT 1');
      return send(response, 200, { status: 'ok', database: 'reachable' });
    } catch (error) {
      console.error('health probe could not reach database:', error.code || 'connection error');
      return send(response, 503, { status: 'not ready' });
    }
  }

  if (path === '/notes' && (request.method === 'GET' || request.method === 'POST')) {
    await pool.query('CREATE TABLE IF NOT EXISTS notes (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())');

    if (request.method === 'GET') {
      const result = await pool.query('SELECT id, body, created_at FROM notes ORDER BY id');
      return send(response, 200, result.rows);
    }

    let raw = '';
    for await (const chunk of request) raw += chunk;
    const input = JSON.parse(raw || '{}');
    if (typeof input.body !== 'string' || !input.body.trim()) {
      return send(response, 400, { error: 'body must be a non-empty string' });
    }
    const result = await pool.query(
      'INSERT INTO notes (body) VALUES ($1) RETURNING id, body, created_at',
      [input.body.trim()]
    );
    console.log('created note', result.rows[0].id);
    return send(response, 201, result.rows[0]);
  }

  return send(response, 404, { error: 'not found' });
}

const server = http.createServer((request, response) => {
  console.log(request.method, request.url);
  handle(request, response).catch((error) => {
    console.error('request failed:', error.code || error.message);
    if (!response.headersSent) send(response, 503, { error: 'database unavailable' });
    else response.end();
  });
});

server.listen(port, '0.0.0.0', () => {
  console.log('web listening on ' + port + '; database host=' + dbHost + '; mode=' + mode);
});

process.on('SIGTERM', () => {
  server.close(() => pool.end().finally(() => process.exit(0)));
});

قبل از Compose خود برنامه را سریع بخوان و قراردادهایش را پیدا کن: نام متغیرهای اتصال چیست؟ health چه چیزی را بررسی می‌کند؟ جدول چه زمانی ساخته می‌شود؟ هرچه این قراردادها روشن‌تر باشند، compose.yaml بعداً فقط آن‌ها را wiring می‌کند.

Without a local database, GET / should respond while /health returns 503. That is intentional: the process started, but its PostgreSQL-dependent capability is not ready. Under Compose, DB_HOST=postgres names the service. Only when the app runs directly on the host and the database is also on the host might you use localhost; never use it from the web container.

اجرای محلی برنامه · پیش از ComposeRun the starter locally · before Compose
cd web
npm install
npm start

# in another terminal
curl.exe -i http://127.0.0.1:3000/
curl.exe -i http://127.0.0.1:3000/health

فایل package-lock.json را که npm install می‌سازد نگه دار. اگر PostgreSQL محلی نداری، پاسخ 503 سلامت را ثبت کن و برو سراغ ساخت کل محیط؛ starter همچنان ثابت کرده web فرایند و مسیر ساده اجرا می‌شوند.

Keep the package-lock.json generated by npm install. If PostgreSQL is not installed locally, record the 503 health response and move on to building the full environment; the starter has still shown that the web process and basic route run.

مسیر پروژه: اول ارتباط، بعد ماندگاری، بعد سخت‌ترکردن شرایطProject path: from two containers to durable data

گام‌ها را به همین ترتیب برو: دو سرویس را بالا بیاور، اتصال با service name را ثابت کن، volume را اضافه کن، secret را از متن عادی بیرون ببر، healthcheckها را تنظیم کن و بعد down/up را روی دادهٔ واقعی آزمایش کن. اگر از اول همه‌چیز را یک‌جا بنویسی، وقتی خطا دیدی نمی‌دانی کدام تصمیم خراب بوده است.

Each step makes one bounded change and produces evidence. Do not start with docker compose down --volumes; you have not yet established which resource owns the data.

گام ۱ — سرویس DB را تعریف کنStep 1 — define the DB service

تا وقتی دقیقاً نمی‌دانی داده کجا نشسته، docker compose down --volumes نزن. در این پروژه قرار است خودت با یک note واقعی ببینی container می‌تواند برود و داده بماند. حذف volume فقط برای دادهٔ کاملاً disposable و بعد از ثبت شواهد انجام می‌شود.

Create a Compose file that starts PostgreSQL from its official image. It needs an initial user, database, and disposable password. Do not add a host port.

راهنماییHint

برای این پروژه version را روی postgres:17 نگه دار تا مسیر داده با فصل ۶ و image آزمایش‌شده هماهنگ باشد. فرمان docker compose ps ممکن است برای آن 5432/tcp نشان بدهد؛ این به‌تنهایی publish روی میزبان نیست.

Keep this project on postgres:17 so its data path matches Chapter 6 and the tested image. docker compose ps may show 5432/tcp; that alone is not a host-published port.

گام ۲ — web را به PostgreSQL وصل کنStep 2 — connect web to PostgreSQL

Compose network و DNS داخلی را به کار بگیر. درخواست از web به hostname service یعنی postgres می‌رود، نه localhost و نه IPای که دستی پیدا کرده‌ای.

Use Compose networking and internal DNS. Web connects to the service hostname postgres, not localhost and not a manually discovered IP.

راهنماییHint

برنامه از DB_HOST می‌خواند. برای اثبات DNS، از داخل web نام را با Node resolve کن؛ سپس یک note بساز و بخوان تا نشان دهی فقط نام resolve نشده، query واقعی هم موفق بوده است.

The app reads DB_HOST. Prove DNS by resolving the name from inside web with Node, then create and read a note to show that resolution was followed by a successful query.

گام ۳ — data پوشه را به named volume وصل کنStep 3 — mount a named volume at the data directory

پایگاه‌داده fileها باید بیرون از چرخهٔ عمر container باشند. volume را به مسیر data مخصوص همین major version وصل کن و آن را در ریشهٔ Compose تعریف کن.

Database files must outlive their container. Mount a named volume at the data path for this image major version and declare it at the Compose top level.

راهنماییHint

برای postgres:17 مسیر رسمی این تمرین /var/lib/postgresql/data است. upstream image از PostgreSQL 18 به بعد مسیر PGDATA و مقصد volume را تغییر داده؛ major version را بی‌برنامه عوض نکن و مسیر را با docs همان image دوباره بررسی کن.

For postgres:17, this project uses /var/lib/postgresql/data. The upstream image changed PGDATA and its volume target in PostgreSQL 18; do not change major versions casually—recheck that image’s documentation and plan a database upgrade.

گام ۴ — رمز را از تنظیمات جدا کنStep 4 — separate the password from configuration

APP_MODE، port، نام پایگاه‌داده و user در فایل .env یا مقدارهای غیرحساس Compose جا دارند. رمز نه. یک فایل secret محلی بساز، آن را از Git خارج نگه دار و فقط به دو service لازم grant کن.

APP_MODE, ports, and database/user names belong in non-secret configuration. The password does not. Create a local secret file, keep it out of Git, and grant it only to the two services that need it.

راهنماییHint

همان قرارداد فصل ۹: Compose فایل را زیر /run/secrets/ mount می‌کند؛ web مسیر را در DB_PASSWORD_FILE می‌گیرد و PostgreSQL رسمی مسیر را در POSTGRES_PASSWORD_FILE. _FILE قرارداد برنامه/image است، نه خاصیت جادویی هر environment variable.

Use Chapter 9’s pattern: Compose mounts the file under /run/secrets/; web receives its path in DB_PASSWORD_FILE, and the official PostgreSQL image receives its path in POSTGRES_PASSWORD_FILE. _FILE is an app/image convention, not magic behavior for every environment variable.

گام ۵ — health و ترتیب آغاز را کامل کنStep 5 — add healthchecks and startup gating

web health باید درخواست HTTP بدهد و DB health باید queryای را اجرا کند که user و پایگاه‌داده هدف را واقعاً پیدا کند. سپس web را به service_healthy وابسته کن.

The web healthcheck should make an HTTP request; the DB healthcheck should run a query that actually finds the target user and database. Then gate web startup on service_healthy.

راهنماییHint

pg_isready می‌سنجد server اتصال می‌پذیرد یا نه؛ مستند رسمی PostgreSQL می‌گوید برای این پاسخ لازم نیست user، رمز یا پایگاه‌داده درست باشند. برای drill تشخیص اشتباه user/پایگاه‌داده از psql و SELECT 1 استفاده کن. healthcheck هم باید ارزان بماند.

pg_isready tests whether the server accepts connections; PostgreSQL’s official docs state that the supplied user, password, or database need not be valid for that status. For a wrong-user/database drill, use psql with SELECT 1. Keep the probe cheap.

گام ۶ — همه‌چیز را با یک فرمان بالا بیاورStep 6 — start the whole environment with one command

web را از Dockerfile build کن؛ user غیرریشه، restart policy و resource limitها را برای web تنظیم کن. PostgreSQL هم memory/CPU/PID ceiling معقول داشته باشد. فایل نهایی باید با docker compose config قابل resolve باشد.

Build web from its Dockerfile; configure a non-root user, restart policy, and resource limits for web. Give PostgreSQL reasonable memory/CPU/PID ceilings too. The finished model should resolve with docker compose config.

راهنماییHint

healthcheck و سقف‌های container را در serviceهای Compose تعریف کن. برای Compose محلی از fieldهای معمول service مثل mem_limit، cpus و pids_limit استفاده کن؛ deploy.resources را بدون بررسی platform به‌عنوان راه‌حل عمومی فرض نکن.

Put healthchecks and container ceilings on the Compose services. For local Compose, use the regular service fields mem_limit, cpus, and pids_limit; do not assume deploy.resources behaves universally without checking the target platform.

گام ۷ — note بساز؛ حالا container را عوض کنStep 7 — create a note, then replace a container

یک note بساز و شناسه/متنش را ثبت کن. اول web را حذف و دوباره بساز؛ بعد کل stack را با down جمع کن و با up برگردان. پس از هر دو آزمایش، همان note باید خوانده شود.

Create a note and record its ID and text. First remove and recreate web; then bring the stack down with down and start it again with up. The same note must be readable after both experiments.

راهنماییHint

down به‌طور عادی named volume را حذف نمی‌کند. down --volumes چرخهٔ دیگری است؛ volumeای را که هنوز مالکیتش را نمی‌دانی پاک نکن.

Ordinary down does not remove a named volume. down --volumes is a different lifecycle; do not delete a volume whose ownership you have not verified.

گام ۸ — شبکه، volume و log را بازبینی کنStep 8 — inspect the network, volume, and logs

بعد از اجرای سالم، ببین Compose چه network و volumeای ساخته. log هر دو service را بخوان؛ اگر app note را نشان می‌دهد ولی DB restart می‌شود، سطح مشکل یکی نیست.

After a successful run, inspect the network and volume Compose created. Read logs from both services; a web response and a restarting DB are not evidence of the same thing.

راهنماییHint

اگر پوشهٔ پروژه را project2-notes نامیده‌ای، نام‌های پیش‌فرض معمولاً project2-notes_app-net و project2-notes_pgdata خواهند بود. ابتدا docker compose ls و docker volume ls را ببین؛ روی نام resource حدس نزن.

If the project directory is named project2-notes, default resource names are typically project2-notes_app-net and project2-notes_pgdata. Check docker compose ls and docker volume ls first; do not guess resource names.

یک checkpoint پشتیبان بگیر و ثابت کن فایل فقط اسمش پشتیبان نیستBackup checkpoint: create a file and inspect it

قبل از بازی با lifecycle، از دادهٔ تمرینی dump بگیر. هدف ساختن استراتژی پشتیبان تولیدی نیست؛ می‌خواهیم فرق «داده‌ای که داخل volume زنده است» با «خروجی ساخته‌شده پشتیبانی که جدا نگه داشته‌ای» را لمس کنیم.

Before the down/up experiment, dump the disposable project data. This is not a production backup plan; the point is to distinguish a live volume from a backup artifact and prove PostgreSQL tooling can read the artifact.

تهیهٔ پشتیبان روی میزبان و بازرسی نسخهBack up to the project host and inspect the copy
mkdir backups
docker compose exec -T postgres pg_dump -U notes_app -d notes -Fc -f /tmp/notes.dump
docker compose cp postgres:/tmp/notes.dump ./backups/notes.dump
docker compose cp ./backups/notes.dump postgres:/tmp/notes-from-host.dump
docker compose exec -T postgres pg_restore --list /tmp/notes-from-host.dump

بعد از ساخت فایل، فقط به وجودش اکتفا نکن. با ابزار PostgreSQL ساختار dump را inspect کن یا آن را در محیط disposable بازخوانی کن. backupی که هیچ‌وقت خوانده نشده، هنوز چیزی را ثابت نمی‌کند.

The host file backups/notes.dump must exist, and the last command should list the archive TOC. This is not a full test restore, but it is stronger than creating an empty or unreadable file. The database in the volume remains the live source. Production still needs separate design for isolation, retention, encryption, and regular restore tests.

چهار خرابی عمدی؛ هر بار فقط یک فرض را بشکنFour deliberate failures; trace each symptom to its cause

سناریوها جدا هستند: DB_HOST=localhost، مسیر secret اشتباه، healthcheck پایگاه‌داده با user یا پایگاه‌داده غلط و حذف/recreate شدن web. قبل از تغییر، compose ps و log سرویس مربوط را نگه دار؛ بعد فقط همان چیزی را اصلاح کن که مدرک به آن اشاره می‌کند.

Run each drill against this disposable dataset and capture compose ps plus the relevant logs before changing anything. After repair, repeat the same request or probe; “I no longer see an error” is not enough without retesting the same path.

خرابی ۱ — DB_HOST=localhostFailure 1 — DB_HOST=localhost

بعد از repair همان درخواست یا probe را دوباره تکرار کن. اگر web دوباره بالا آمد ولی note قبلی نیست، شاید مشکل اصلی storage بوده و فقط connection را درست کرده‌ای. هر خرابی acceptance خودش را دارد.

Change only DB_HOST in .env from postgres to localhost and recreate web. The root endpoint may answer, while health and notes cannot reach the DB. Resolve the address from web, read docker compose logs web, and run a DNS lookup inside the service. Repair the hostname to postgres.

خرابی ۲ — secret file مسیر غلطFailure 2 — wrong secret-file path

در environment مربوط به web مسیر DB_PASSWORD_FILE را عمداً به /run/secrets/missing_password تغییر بده، ولی secret grant را دست‌نزن. web ممکن است با ENOENT خارج شود و restart شود؛ PostgreSQL می‌تواند healthy بماند. compose ps -a و log web نشان می‌دهند شکست هنگام خواندن فایل رخ داده، پیش از اتصال TCP/SQL. repair کن و web را recreate کن؛ مقدار رمز را برای debug چاپ نکن.

Change web’s DB_PASSWORD_FILE to /run/secrets/missing_password but leave the secret grant alone. Web may exit with ENOENT and restart while PostgreSQL remains healthy. compose ps -a and web logs show the failure happened while reading the file, before TCP or SQL. Fix the path and recreate web; never print the password to debug it.

خرابی ۳ — DB healthcheck user یا پایگاه‌داده اشتباه داردFailure 3 — DB healthcheck targets the wrong user or database

در دستور probe مقدار user یا پایگاه‌داده را به نامی که وجود ندارد تغییر بده. اگر probe با psql ... SELECT 1 است، DB فرایند می‌تواند Running بماند ولی health به unhealthy برسد؛ health log خطای role/پایگاه‌داده را می‌دهد. این فرق «فرایند زنده» با «آزمون دقیقاً به مقصد درست وصل شد» است. پارامترهای healthcheck را با POSTGRES_USER و POSTGRES_DB resolveشده هماهنگ کن و health را دوباره ببین.

Change the probe’s user or database to a name that does not exist. With a psql ... SELECT 1 probe, the DB process can remain Running while health becomes unhealthy; health logs show the role/database error. That separates “process alive” from “probe reached its intended target.” Align the healthcheck parameters with the resolved POSTGRES_USER and POSTGRES_DB, then recheck health.

خرابی ۴ — web را حذف و دوباره بسازFailure 4 — remove and recreate web

یک note بساز و شناسه‌اش را یادداشت کن. سپس فقط web را حذف کن؛ بعد docker compose up -d web بزن و note را بخوان. اگر note نیست، حدس نزن که Docker داده را پاک کرده: ابتدا inspect کن آیا app واقعاً به DB service و named volume همین پروژه وصل است یا volume تازه‌ای ایجاد شده.

Create a note and record its ID. Remove only web, then run docker compose up -d web and fetch the note. If it is missing, do not immediately blame Docker for deleting data: inspect whether the app reached this project’s DB and whether it mounted the expected named volume or a new one.

راهنمایی: کدام لایه شکست؟Hint: which boundary failed?

از کم‌هزینه‌ترین شاهدها شروع کن: docker compose config مقدارهای resolveشده را می‌گوید؛ compose ps -a وضعیت serviceها را؛ compose logs web postgres خطای فرایند را؛ DNS lookup داخل web نام مقصد را؛ docker network inspect اتصال‌ها را؛ و docker volume inspect هویت volume را. فرمانی را انتخاب کن که بین دو علت محتمل فرق بگذارد.

Start with the cheapest discriminating evidence: docker compose config shows resolved values; compose ps -a shows service state; compose logs web postgres shows process errors; a DNS lookup from web tests the destination name; docker network inspect shows attachments; and docker volume inspect identifies the volume. Choose a check that distinguishes plausible causes.

قبولی یعنی هم‌تیمی بتواند clone کند، بالا بیاورد و همان شواهد را تکرار کندAcceptance: a teammate can clone, start, and reproduce the evidence

پروژه باید از روی README قابل‌اجرا باشد: secret آزمایشی را بساز، Compose را بالا بیاور، note ثبت کن، سرویس‌ها را پایین بیاور و دوباره بالا بیاور. همان note باید برگردد. از میزبان هم پایگاه‌داده نباید port منتشرشده داشته باشد.

Prerequisites: Node and Docker Compose using Linux containers. Put the project in a directory named project2-notes so resource names below are predictable. Create the secret file with a disposable value and do not commit it. This exercise’s file-backed Compose secrets are for Linux containers.

بررسی، build و اجرا · یک فرمان برای کل محیطResolve, build, and start · one command for the complete environment
docker compose config --quiet
docker compose up -d --build
docker compose ps

قبولی فقط با یک screenshot از صفحه تمام نمی‌شود. reviewer باید بتواند health دو سرویس، network، volume، user برنامه، secret path و persistence را با دستورهای مشخص بررسی کند. اگر چیزی فقط «قرار است» درست باشد ولی راه سنجش نداری، هنوز بخشی از تحویل ناقص است.

The first command should validate cleanly, the second build/start both services, and the third should show both as healthy after a few probes. Only web should have a host mapping such as 127.0.0.1:8080->3000/tcp. 5432/tcp for PostgreSQL is an internal exposed port, not a host publication.

آزمودن API · نوشتن و خواندن دادهٔ ماندگارExercise the API · create and read persistent data
curl.exe --fail http://127.0.0.1:8080/
curl.exe --fail http://127.0.0.1:8080/health
curl.exe --fail -X POST http://127.0.0.1:8080/notes -H "Content-Type: application/json" -d '{"body":"survives replacement"}'
curl.exe --fail http://127.0.0.1:8080/notes

در پاسخ root باید dbHost برابر postgres باشد؛ health باید 200 بدهد؛ note ساخته‌شده باید در GET بعدی دیده شود. درخواست به میزبان از port web می‌رود؛ هیچ درخواست میزبان به port 5432 لازم نیست.

The root response should show dbHost as postgres; health should return 200; and the created note should appear in the next GET. Host requests use web’s port; no host request to 5432 is needed.

بازبینی هویت، network و فضای ذخیره‌سازیInspect identity, network, and storage
docker compose exec web node -e "console.log('DB_HOST='+process.env.DB_HOST); require('node:dns').lookup(process.env.DB_HOST,(e,a)=>{if(e) throw e; console.log('resolved='+a)})"
docker compose exec web id
docker compose logs --tail=30 web postgres
docker network inspect project2-notes_app-net
docker volume inspect project2-notes_pgdata
docker compose exec web node -e "const fs=require('node:fs'); console.log('DB_PASSWORD env present:',Object.hasOwn(process.env,'DB_PASSWORD')); fs.accessSync(process.env.DB_PASSWORD_FILE); console.log('password file readable: true')"
docker image inspect project2-notes-web:local --format '{{json .Config.Env}}'

این شواهد جداگانه پاسخ می‌دهند: hostname به IP داخلی resolve می‌شود؛ id باید user غیرریشه را نشان دهد؛ لاگ‌ها باید درخواست و درج note را ثبت کرده باشند؛ inspectها network و named volume را نشان می‌دهند. محیط web نباید متغیر DB_PASSWORD داشته باشد؛ در image تنظیمات هم نباید رمز literal باشد. چاپ مسیر فایل مجاز است؛ چاپ محتوای آن نه.

These checks answer different questions: the hostname resolves to an internal address; id should show a non-root user; logs should show the request and inserted note; inspect identifies the network and named volume. Web’s environment must not contain DB_PASSWORD, and image config must not contain the password literal. Printing the file path is fine; printing its contents is not.

اکنون web را با docker compose rm -sf web حذف و با docker compose up -d web دوباره بساز. همان note را بخوان. سپس آزمون چرخهٔ عمر volume را اجرا کن:

Now remove web with docker compose rm -sf web and recreate it with docker compose up -d web. Fetch the same note. Then run the volume lifecycle test:

named volume باید پس از down/up بماندThe named volume must survive down/up
docker compose down
docker compose up -d
curl.exe --fail http://127.0.0.1:8080/notes
docker compose ps

GET باید همان note را برگرداند و health دوباره healthy شود. این اثبات می‌کند داده در named volume بوده، نه container قبلی web یا PostgreSQL. down شبکه و containerهای پروژه را حذف می‌کند؛ volume نام‌دار را نگه می‌دارد.

GET should return the same note and health should become healthy again. This proves the data lived in the named volume, not in the old web or PostgreSQL container. down removes the project’s containers and network while keeping its named volume.

راه‌حل مرجع برای مقایسه است؛ نه نقطهٔ شروعReference implementation; open it after building yours

اگر هنوز compose.yaml خودت را ننوشته‌ای، این بخش را باز نکن. اول نسخه‌ای بساز که بتوانی توضیح بدهی هر resource کجا ایجاد می‌شود، هر secret را کدام service می‌بیند و کدام اتصال از میزبان عبور می‌کند.

If you have not written Compose yet, do not copy this file first. Build a version where you can explain where each resource is created, which service receives each secret, and which connection crosses the host boundary. Then compare it with this implementation.

.env.example · فقط تنظیمات، بدون رمز.env.example · configuration only, no password
APP_PORT=8080
APP_MODE=development
DB_HOST=postgres
DB_NAME=notes
DB_USER=notes_app
.gitignore · نگه‌داشتن دادهٔ محلی بیرون از Git.gitignore · keep local material local
/.env
/secrets/db_password
/backups/

بعد راه‌حل مرجع را با معماری خودت مقایسه کن. ممکن است نام‌ها یا ترتیب بعضی کلیدها فرق داشته باشد؛ چیزی که باید یکسان بماند رفتار است: پایگاه‌داده خصوصی، دادهٔ ماندگار، health معنادار و webی که با نام سرویس به PostgreSQL می‌رسد.

For setup, copy .env.example to a local .env and create the secrets directory. Use an editor to create secrets/db_password with a disposable test password; do not put it in a command that may remain in shell history. Never commit the password file or dump. Restrict host permissions on Linux/WSL; Compose file-backed secrets do not reliably remap host file permissions on every platform.

آماده‌سازی فایل‌های محلی و نادیده‌گرفته‌شده · بدون رمز در فرمانPrepare local, ignored files · no password in the command
# PowerShell
Copy-Item .env.example .env
New-Item -ItemType Directory -Force secrets | Out-Null

# Linux / WSL
cp .env.example .env
mkdir -p secrets
web/Dockerfile
FROM node:22-alpine
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node server.js ./server.js
USER node
EXPOSE 3000
CMD ["node", "server.js"]
web/.dockerignore
node_modules
.git
.env
.env.*
npm-debug.log*

این app مرحلهٔ compile جدا ندارد؛ multi-stage صرفاً برای تکرار الگو لازم نیست. image رسمی Node کاربر node دارد؛ فایل برنامه با همان user خواندنی است و فرایند پس از USER node اجرا می‌شود. secretها در context وب نیستند و از build وارد image نمی‌شوند.

This app has no separate compile step, so a multi-stage build is not required just to repeat a pattern. The official Node image provides the node user; the app file is readable by that user and the process runs after USER node. Secrets are outside the web build context and never enter the image.

compose.yaml · دو سرویس، DB خصوصی و دادهٔ ماندگارcompose.yaml · two services, private database, durable data
name: project2-notes

services:
  web:
    image: project2-notes-web:local
    build: ./web
    restart: unless-stopped
    ports:
      - "127.0.0.1:${APP_PORT:-8080}:3000"
    environment:
      PORT: "3000"
      APP_MODE: ${APP_MODE:-development}
      DB_HOST: ${DB_HOST:-postgres}
      DB_PORT: "5432"
      DB_NAME: ${DB_NAME:-notes}
      DB_USER: ${DB_USER:-notes_app}
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:'+process.env.PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    mem_limit: 256m
    cpus: 0.50
    pids_limit: 100
    networks:
      - app-net

  postgres:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_NAME:-notes}
      POSTGRES_USER: ${DB_USER:-notes_app}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test:
        - CMD-SHELL
        - psql -U ${DB_USER:-notes_app} -d ${DB_NAME:-notes} -tAc 'SELECT 1' | grep -qx 1
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 15s
    mem_limit: 512m
    cpus: 1.0
    pids_limit: 128
    networks:
      - app-net

volumes:
  pgdata:

networks:
  app-net:
    driver: bridge

secrets:
  db_password:
    file: ./secrets/db_password

هیچ portsای زیر postgres وجود ندارد؛ فقط web روی loopback میزبان publish شده است. هر دو service روی network مشترک‌اند و DNS نام postgres را می‌دهد. secret به هر دو container mount می‌شود اما مقدارش در Compose environment، Dockerfile، image تنظیمات یا Git قرار نمی‌گیرد. healthcheck PostgreSQL queryای را با user و DB هدف اجرا می‌کند، و شرط startup تا healthy شدن آن صبر می‌کند.

There is no ports entry under postgres; only web is published on the host loopback. Both services share a network where DNS resolves postgres. The secret is mounted into both containers but its value is absent from Compose environment, Dockerfile, image config, and Git. The PostgreSQL healthcheck queries the target user and database, and the startup condition waits for it to become healthy.

این secret الگوی محلی Compose است، نه سامانهٔ مدیریت کلید تولیدیThis is a local Compose secret pattern, not a production key-management system

فایل محلی نباید در Git باشد، اما همچنان روی میزبان به شکل فایل وجود دارد و دسترسی به میزبان اهمیت دارد. رمز آزمایشی واقعی نیست. تغییر secret file هم رمز roleای را که قبلاً در volume مقداردهی شده خودکار عوض نمی‌کند؛ این پروژه volume را initialize می‌کند و rotation را تمرین نمی‌دهد. مقدارهای آغازین POSTGRES_USER و POSTGRES_DB نیز فقط هنگام راه‌اندازی data پوشه خالی اعمال می‌شوند؛ volume موجود را با تغییر .env دوباره مقداردهی نمی‌کنی.

The local file must stay out of Git, but it still exists on the host, so host access matters. The sample password is disposable. Changing the secret file does not automatically rotate a role password already initialized in the volume; this project does not practice credential rotation. Initial POSTGRES_USER and POSTGRES_DB values are applied only when the data directory is empty; changing .env does not reinitialize an existing volume.

نکتهٔ مهم دربارهٔ role: image رسمی PostgreSQL برای مقدار POSTGRES_USER نقش آغازین را با امتیاز superuser می‌سازد. این پروژه از همان role برای ساده‌ماندن مسیر تمرینی استفاده می‌کند؛ در سرویس واقعی، role برنامه را جدا و با حداقل مجوز لازم بساز و رمز bootstrap را به web نده.

Important role caveat: the official PostgreSQL image creates the initial POSTGRES_USER as a PostgreSQL superuser. This project reuses that role to keep the learning path small; for a real service, create a separate least-privilege application role and do not give web the bootstrap credential.

پس از راه‌اندازی، این شواهد را تحویل بدهAfter startup, hand off this evidence

یک note، هر دو سرویس سالم و پاک‌سازی هدفمندOne note, both healthy services, targeted cleanup
docker compose up -d --build
docker compose ps
curl.exe --fail -X POST http://127.0.0.1:8080/notes -H "Content-Type: application/json" -d '{"body":"project evidence"}'
docker compose logs --tail=30 web postgres
docker compose down
docker compose up -d
curl.exe --fail http://127.0.0.1:8080/notes

نسخهٔ موفق پاسخ GET حاوی همان note را دارد. اگر فقط app جواب می‌دهد ولی DB unhealthy است، پروژه قبول نیست. اگر هر دو healthyاند ولی GET خالی است، persistence هنوز ثابت نشده. اگر log برای debug لازم شد، log را بخوان؛ رمز را هرگز چاپ نکن.

A successful run returns the same note from GET. If web responds but DB is unhealthy, the project has not passed. If both are healthy but GET is empty, persistence is unproven. Read logs when needed; never print the password.

بازبینی نهاییFinal review

  • آیا یک docker compose up -d --build هر دو service را می‌سازد و بالا می‌آورد؟
  • آیا تنها web port روی میزبان دارد و DB از service name قابل‌دسترسی است؟
  • آیا رمز فقط از فایل secret می‌آید و در image/environment/Git دیده نمی‌شود؟
  • آیا web با user غیرریشه، restart policy و limitهای ثبت‌شده اجرا می‌شود؟
  • آیا هر دو healthcheck healthy می‌شوند و web در شروع منتظر DB می‌ماند؟
  • آیا لاگ‌ها هر دو service با Compose خوانده می‌شوند؟
  • آیا note پس از حذف web و پس از down/up باقی می‌ماند؟
  • آیا پشتیبان file روی میزبان موجود است و pg_restore --list آن را می‌خواند؟
  • آیا دست‌کم یک خطا را از روی مدرک تشخیص دادی و همان آزمون را پس از repair تکرار کردی؟
  • Does one docker compose up -d --build build and start both services?
  • Is only web published on the host, with DB reached by service name?
  • Does the password arrive only through a secret file and stay out of image, environment, and Git?
  • Does web run non-root with a restart policy and recorded limits?
  • Do both healthchecks become healthy, with web initially waiting for DB?
  • Can Compose show logs from both services?
  • Does the note survive web replacement and down/up?
  • Does the backup exist on the host and can pg_restore --list read it?
  • Did you diagnose at least one failure from evidence and repeat the same check after repair?

پاک‌سازی معمول تمرین: docker compose down؛ named volume و note را نگه می‌دارد. اگر واقعاً می‌خواهی دادهٔ disposable را پاک کنی، اول نام پروژه و محتوای volume را تأیید کن، dump را نگه دار و فقط آن‌وقت docker compose down --volumes اجرا کن. این دستور volume داده را حذف می‌کند و مسیر بازگشتش پشتیبان است، نه حدس.

Normal lab cleanup is docker compose down; it keeps the named volume and notes. If you truly want to erase disposable data, first verify the project name and volume contents, retain the dump, and only then run docker compose down --volumes. That removes the data volume; the recovery path is a backup, not a guess.