Vocab Run it
1 · Words 2 · Shape 3 · Database 4 · Backend 5 · Frontend 6 · Dockerfile 7 · Compose 8 · Run it 9 · Docker Hub 10 · Gotchas

🐳 Docker Calendar Lab

You are going to run two computers on your one computer. One of them does nothing but remember things — that is PostgreSQL. The other one shows a calendar to people and writes what they type into the first one. They talk over a private network that nothing else can reach. 你将在自己的一台电脑上运行两台计算机。其中一台只负责记住东西,那就是 PostgreSQL。另一台把日历展示给用户,并把用户输入的内容写进第一台里。 它们通过一个别人碰不到的私有网络通信。 คุณกำลังจะรันคอมพิวเตอร์สองเครื่องบนเครื่องเดียวของคุณ เครื่องหนึ่งทำหน้าที่จำอย่างเดียว นั่นคือ PostgreSQL ส่วนอีกเครื่องแสดงปฏิทินให้ผู้ใช้ดู และเขียนสิ่งที่ผู้ใช้พิมพ์ลงไปในเครื่องแรก ทั้งสองคุยกันผ่านเครือข่ายส่วนตัวที่ไม่มีใครเข้าถึงได้

Everything on this page was built and run before it was written. The commands are the commands that worked, and the gotchas are the ones that actually bit. 本页所有内容都是先真正跑通、再写下来的。这里的命令就是当时能用的命令, 踩坑部分也都是真的踩过的坑。 ทุกอย่างในหน้านี้ถูกสร้างและรันจริงก่อนจะเขียนขึ้นมา คำสั่งที่เห็นคือคำสั่งที่ใช้ได้จริง และข้อผิดพลาดที่เตือนไว้ก็คือสิ่งที่เจอมาจริง ๆ

Two labs come before this one. Docker Lab teaches containers themselves — installing Docker, putting a website in a box, pushing to Docker Hub. Postgres Lab teaches the SQL language — SELECT, JOIN, changing and removing rows. This page is the one that joins them: a real backend that writes to a real database, in containers, published. 这一页之前有两个实验。Docker 实验 讲容器本身——安装 Docker、把网站装进盒子、推送到 Docker Hub。 Postgres 实验讲 SQL 语言——SELECT、JOIN、修改和删除数据。 而这一页是把它们接起来的那一环:一个真正的后端,把数据写进真正的数据库, 跑在容器里,并且发布出去。 มีสองแล็บที่ควรเรียนก่อนหน้านี้ Docker Lab สอนเรื่องคอนเทนเนอร์โดยตรง ทั้งการติดตั้ง Docker การเอาเว็บใส่กล่อง และการส่งขึ้น Docker Hub ส่วน Postgres Lab สอนภาษา SQL ทั้ง SELECT, JOIN การแก้ไขและลบข้อมูล หน้านี้คือหน้าที่เชื่อมทั้งสองเข้าด้วยกัน คือแบ็กเอนด์จริงที่เขียนลงฐานข้อมูลจริง ทำงานในคอนเทนเนอร์ และเผยแพร่ออกไป
The one idea. A container is not a small virtual machine. It is one process, in its own private box, that dies the moment that process stops. Anything you want to survive that death has to be written somewhere outside the box — which is what a volume is for, and why the database gets one. 核心想法。容器不是小型虚拟机。它就是一个进程,装在自己的私有盒子里, 进程一停,容器就没了。任何你希望在它消失后还留下的东西,都必须写到盒子外面—— 这就是数据卷的用途,也是数据库要挂一个卷的原因。 แนวคิดหลัก คอนเทนเนอร์ไม่ใช่เครื่องเสมือนขนาดเล็ก แต่คือโปรเซสเดียว ที่อยู่ในกล่องส่วนตัวของมัน และจะหายไปทันทีที่โปรเซสนั้นหยุด สิ่งใดที่คุณอยากให้อยู่รอดหลังจากนั้น ต้องเขียนไว้นอกกล่อง นั่นคือหน้าที่ของ volume และเป็นเหตุผลที่ฐานข้อมูลต้องมีมัน

🔤 The words you need

Twenty-two words. Learn these first and every command below explains itself. 二十二个词。先学会这些,下面每一条命令都会自己解释自己。 ยี่สิบสองคำ เรียนคำเหล่านี้ก่อน แล้วทุกคำสั่งข้างล่างจะอธิบายตัวมันเอง

🧭 The shape of the stack

Two containers, one private network, one port open to you.

整个架构的形状 — 两个容器、一个私有网络、只有一个端口对你开放。
รูปร่างของระบบ — สองคอนเทนเนอร์ หนึ่งเครือข่ายส่วนตัว และเปิดพอร์ตให้คุณแค่พอร์ตเดียว
webNode.js + Express
serves the calendar page
and the REST API
ddtraveller/calendar-lab
── SQL over the
private network ──▶
dbPostgreSQL 17
stores the events
postgres:17-alpine
volume pgdata

Your browser reaches localhost:8080, which Docker forwards to port 3000 inside web. Nothing reaches db except web, because db publishes no ports at all. That is not laziness — it is the single most valuable security habit in this whole lab.

你的浏览器访问 localhost:8080,Docker 把它转发到 web 容器内部的 3000 端口。除了 web,没有任何东西能碰到 db,因为 db 根本没有对外开放任何端口。这不是偷懒——这是整个实验里最有价值的一个安全习惯。
เบราว์เซอร์ของคุณเข้าที่ localhost:8080 แล้ว Docker ส่งต่อไปยังพอร์ต 3000 ภายในคอนเทนเนอร์ web ไม่มีอะไรเข้าถึง db ได้นอกจาก web เพราะ db ไม่ได้เปิดพอร์ตออกมาเลย นี่ไม่ใช่ความขี้เกียจ แต่เป็นนิสัยด้านความปลอดภัยที่มีค่าที่สุดในแล็บนี้

The files

calendar_lab/ Dockerfile # how to build the web image docker-compose.yml # how the two containers fit together .dockerignore # what NOT to copy into the image db/ init.sql # schema, run once on an empty volume server/ package.json server.js # the JavaScript backend public/ index.html # the calendar page app.js # the calendar logic style.css

🗄️ The database

One table. A calendar event is not a complicated thing.

数据库 — 就一张表。一条日历事件本来就不是什么复杂的东西。
ฐานข้อมูล — ตารางเดียว เพราะกิจกรรมในปฏิทินไม่ใช่เรื่องซับซ้อน

db/init.sql

CREATE TABLE IF NOT EXISTS events ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, event_date DATE NOT NULL, start_time TIME, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS events_date_idx ON events (event_date);

SERIAL PRIMARY KEY means Postgres invents the id for you and guarantees it is unique. The index on event_date matters because every screen of this app asks the same question — what is happening in this month? — and an index is what stops that question from reading the whole table.

SERIAL PRIMARY KEY 表示 id 由 Postgres 自动生成并保证唯一。 给 event_date 建索引很重要,因为这个应用的每一屏都在问同一个问题—— 这个月有什么事?——而索引正是让这个问题不必扫描整张表的东西。
SERIAL PRIMARY KEY หมายความว่า Postgres สร้าง id ให้เองและรับประกันว่าไม่ซ้ำ ส่วนดัชนีบน event_date สำคัญ เพราะทุกหน้าจอของแอปนี้ถามคำถามเดียวกันคือ เดือนนี้มีอะไรบ้าง และดัชนีคือสิ่งที่ทำให้ไม่ต้องอ่านทั้งตาราง
The gotcha that wastes an afternoon. Files in /docker-entrypoint-initdb.d/ run only once, the first time the data volume is created. Edit init.sql afterwards and absolutely nothing happens — no error, no warning, no new column. To make it run again you must destroy the volume with docker compose down -v, which also destroys your data. 会浪费你一下午的坑。/docker-entrypoint-initdb.d/ 里的文件只在数据卷第一次创建时运行一次。之后你再改 init.sql, 什么都不会发生——没有报错、没有警告、也不会多出新字段。想让它重新运行, 必须用 docker compose down -v 销毁数据卷,而那会连数据一起销毁。 จุดที่ทำให้เสียเวลาทั้งบ่าย ไฟล์ใน /docker-entrypoint-initdb.d/ จะรันครั้งเดียวเท่านั้น คือตอนที่สร้าง volume ครั้งแรก ถ้าคุณแก้ init.sql ทีหลัง จะไม่มีอะไรเกิดขึ้นเลย ไม่มี error ไม่มีคำเตือน ไม่มีคอลัมน์ใหม่ ถ้าอยากให้รันอีกครั้งต้องลบ volume ด้วย docker compose down -v ซึ่งจะลบข้อมูลของคุณไปด้วย

Because of that, the backend also creates the table itself on every boot, using the same IF NOT EXISTS statements. Belt and braces: the image then works against a database that has never seen init.sql — which is exactly what happens when somebody pulls it from Docker Hub.

正因如此,后端在每次启动时也会用同样的 IF NOT EXISTS 语句自己建表。双保险:这样镜像就能在一个从没见过 init.sql 的数据库上正常工作—— 而这正是别人从 Docker Hub 拉取它时会发生的情况。
ด้วยเหตุนี้ แบ็กเอนด์จึงสร้างตารางเองทุกครั้งที่บูตด้วยคำสั่ง IF NOT EXISTS ชุดเดียวกัน เป็นการกันไว้สองชั้น อิมเมจจึงทำงานได้กับฐานข้อมูลที่ไม่เคยเห็น init.sql มาก่อน ซึ่งก็คือสิ่งที่เกิดขึ้นเวลามีคนดึงมันมาจาก Docker Hub

⚙️ The backend

JavaScript on the server. It is the only thing allowed to touch the database.

后端 — 跑在服务器上的 JavaScript。只有它可以碰数据库。
แบ็กเอนด์ — จาวาสคริปต์ที่รันบนเซิร์ฟเวอร์ และเป็นสิ่งเดียวที่แตะฐานข้อมูลได้

Waiting for the database

async function waitForDatabase(retries = 30, delayMs = 1000) { for (let attempt = 1; attempt <= retries; attempt += 1) { try { await pool.query('SELECT 1'); console.log(`[db] connected on attempt ${attempt}`); return; } catch (err) { console.log(`[db] not ready (attempt ${attempt}/${retries}): ${err.code}`); if (attempt === retries) throw err; await new Promise((r) => setTimeout(r, delayMs)); } } }
Verified, not assumed. Started the app with no database at all, then started Postgres fourteen seconds later. The log read [db] not ready (attempt 1/30): EAI_AGAIN and then [db] connected on attempt 2. EAI_AGAIN is a DNS failure — at that moment the name db did not resolve to anything, because the container did not exist yet. 这是验证过的,不是想当然。先在完全没有数据库的情况下启动应用, 十四秒后才启动 Postgres。日志显示 [db] not ready (attempt 1/30): EAI_AGAIN, 接着是 [db] connected on attempt 2EAI_AGAIN 是 DNS 解析失败—— 在那一刻 db 这个名字什么都解析不到,因为那个容器还不存在。 ตรวจสอบจริง ไม่ได้เดา เริ่มรันแอปโดยยังไม่มีฐานข้อมูลเลย แล้วค่อยเริ่ม Postgres หลังจากนั้นสิบสี่วินาที ล็อกขึ้นว่า [db] not ready (attempt 1/30): EAI_AGAIN แล้วตามด้วย [db] connected on attempt 2 โดย EAI_AGAIN คือ DNS ล้มเหลว เพราะตอนนั้นชื่อ db ยังไม่ชี้ไปที่อะไรเลย เนื่องจากคอนเทนเนอร์ยังไม่มีอยู่

One endpoint, and why the $1 matters

app.post('/api/events', wrap(async (req, res) => { const v = validate(req.body || {}); if (v.error) return res.status(400).json({ error: v.error }); const { rows } = await pool.query( `INSERT INTO events (title, event_date, start_time, notes) VALUES ($1, $2, $3, $4) RETURNING *`, [v.title, v.date, v.time, v.notes], ); res.status(201).json(shapeEvent(rows[0])); }));

The values are handed to Postgres separately from the SQL text. They are never glued into the query string, so they can never become commands.

这些值是和 SQL 文本分开交给 Postgres 的。它们绝不会被拼接进查询字符串, 所以永远不可能变成命令。
ค่าต่าง ๆ ถูกส่งให้ Postgres แยกต่างหาก จากตัวข้อความ SQL มันไม่เคยถูกนำไปต่อเข้ากับสตริงคำสั่ง จึงไม่มีทางกลายเป็นคำสั่งได้
Tested for real. Posted an event titled x'); DROP TABLE events;--. It was saved as an ordinary event with that literal title, appeared in the calendar as text, and the table was still there afterwards. Build the same query with string concatenation and you would have lost the table. 真的测过。提交了一个标题为 x'); DROP TABLE events;-- 的事件。它被当作一条普通事件保存下来,标题就是那串字面文本,在日历里显示为文字, 之后表还好好地在那里。如果你用字符串拼接来构造同样的查询,这张表就没了。 ทดสอบจริง ลองส่งกิจกรรมที่ชื่อว่า x'); DROP TABLE events;-- เข้าไป มันถูกบันทึกเป็นกิจกรรมธรรมดาที่มีชื่อตามนั้นทุกตัวอักษร แสดงในปฏิทินเป็นข้อความ และตารางก็ยังอยู่ครบ ถ้าเขียนคำสั่งเดียวกันด้วยการต่อสตริง ตารางนั้นคงหายไปแล้ว

The whole API

Method & pathDoesReturns
GET /api/healthChecks the database is reachable{ok:true, events:N}
GET /api/events?month=YYYY-MMLists one montharray of events
POST /api/eventsCreates an event201 + the event
PUT /api/events/:idUpdates an eventthe event, or 404
DELETE /api/events/:idDeletes an event204, or 404

🖥️ The frontend

Plain browser JavaScript. No framework, no build step, no node_modules.

前端 — 纯浏览器 JavaScript。没有框架、没有构建步骤、没有 node_modules。
ฟรอนต์เอนด์ — จาวาสคริปต์ในเบราว์เซอร์ล้วน ๆ ไม่มีเฟรมเวิร์ก ไม่มีขั้นตอนบิลด์

The file you edit is the file the browser runs. For a lab that is a feature, not a limitation: there is no compiler between you and the mistake.

你编辑的就是浏览器运行的那个文件。对一个实验来说这是优点而不是限制: 在你和你的错误之间没有编译器挡着。
ไฟล์ที่คุณแก้คือไฟล์ที่เบราว์เซอร์รันจริง สำหรับแล็บนี่คือข้อดี ไม่ใช่ข้อจำกัด เพราะไม่มีคอมไพเลอร์มาคั่นระหว่างคุณกับข้อผิดพลาด
// Build YYYY-MM-DD from LOCAL parts. Never use toISOString() // for a calendar day: it converts to UTC first, so late-evening // dates in Bangkok (UTC+7) jump to the next day. const iso = (y, m, d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
Timezones will get you twice. Once in the browser, where toISOString() shifts a Bangkok evening into tomorrow, and once in the backend, where the Postgres driver hands back a DATE as a JavaScript Date in the server's timezone. Both ends of this app format dates from explicit parts instead. 时区会坑你两次。一次在浏览器里,toISOString() 会把曼谷的傍晚变成第二天;一次在后端,Postgres 驱动会把 DATE 按服务器时区还原成 JavaScript 的 Date。所以这个应用的两端都改成 用明确的年月日分量来拼日期。 เขตเวลาจะเล่นงานคุณสองรอบ รอบแรกในเบราว์เซอร์ ที่ toISOString() ทำให้ตอนเย็นในกรุงเทพกลายเป็นวันถัดไป และอีกรอบที่แบ็กเอนด์ ซึ่งไดรเวอร์ Postgres คืนค่า DATE มาเป็น Date ตามเขตเวลาของเซิร์ฟเวอร์ ทั้งสองฝั่งของแอปนี้จึงประกอบวันที่จากส่วนประกอบที่ระบุชัดเจนแทน

📦 The Dockerfile

Every line here is doing a job. Read the comments, not just the commands.

Dockerfile — 这里每一行都有它的作用。要读注释,不要只读命令。
Dockerfile — ทุกบรรทัดมีหน้าที่ของมัน อ่านคอมเมนต์ ไม่ใช่แค่คำสั่ง
# syntax=docker/dockerfile:1 # ── build stage ── FROM node:22-alpine AS deps WORKDIR /app # Copy the manifests ALONE first. Docker caches each layer by the # files it touched, so this layer is only rebuilt when a dependency # actually changes — editing server.js no longer reinstalls npm. COPY server/package*.json ./ RUN npm install --omit=dev --no-audit --no-fund # ── runtime stage ── FROM node:22-alpine AS runtime ENV NODE_ENV=production WORKDIR /app # tini gives us a real PID 1 that reaps zombies and forwards signals, # so `docker stop` is instant instead of a 10-second SIGKILL wait. RUN apk add --no-cache tini wget COPY --from=deps /app/node_modules ./node_modules COPY server/ ./ # The node image ships an unprivileged `node` user. Running as root # inside a container is a habit worth not forming. USER node EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \ CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "server.js"]

Why copy package.json on its own?

Docker builds in layers and reuses any layer whose inputs did not change. If you copy the whole project and then run npm install, changing one character in server.js invalidates the copy layer and every layer after it — so npm reinstalls everything, every time. Copying the manifests first puts the slow step behind a file that rarely changes.

Docker 是分层构建的,输入没变的层会被复用。如果你先复制整个项目、 然后才运行 npm install,那么改动 server.js 里的一个字符就会让复制层和它之后的所有层全部失效——于是 npm 每次都要重装一遍。 先复制清单文件,就是把这个慢步骤挡在一个很少变动的文件后面。
Docker บิลด์เป็นชั้น ๆ และใช้ชั้นเดิมซ้ำถ้าอินพุตไม่เปลี่ยน ถ้าคุณคัดลอกทั้งโปรเจกต์ แล้วค่อยรัน npm install การแก้แค่ตัวอักษรเดียวใน server.js จะทำให้ชั้นคัดลอกและทุกชั้นถัดจากนั้นใช้ไม่ได้ npm จึงต้องติดตั้งใหม่ทุกครั้ง การคัดลอกไฟล์ manifest ก่อน คือการเอาขั้นตอนที่ช้าไปไว้หลังไฟล์ที่แทบไม่เปลี่ยน

🧩 docker-compose.yml

One file that says how the two containers fit together.

docker-compose.yml — 用一个文件说清楚两个容器怎么拼在一起。
docker-compose.yml — ไฟล์เดียวที่บอกว่าคอนเทนเนอร์สองตัวประกอบกันอย่างไร
services: db: image: postgres:17-alpine environment: POSTGRES_USER: calendar POSTGRES_PASSWORD: calendar POSTGRES_DB: calendar volumes: - pgdata:/var/lib/postgresql/data - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U calendar -d calendar"] interval: 5s timeout: 3s retries: 10 # No `ports:` on purpose. The database is reachable from `web` # over the private network, but not from your laptop. restart: unless-stopped web: build: . image: ddtraveller/calendar-lab:latest environment: PGHOST: db # the service name IS the hostname PGPASSWORD: calendar ports: - "8080:3000" # host 8080 -> container 3000 depends_on: db: condition: service_healthy restart: unless-stopped volumes: pgdata:
depends_on does not mean what you think. On its own it waits for the database container to start — not for PostgreSQL inside it to accept connections. Postgres takes a few seconds more, so the app connects to nothing, throws ECONNREFUSED, and exits. Adding condition: service_healthy makes compose wait for the healthcheck instead, which is what you actually meant. depends_on 的意思和你想的不一样。它本身只等数据库容器 启动,而不等里面的 PostgreSQL 能接受连接。Postgres 还要再花几秒, 于是应用连了个寂寞,抛出 ECONNREFUSED 然后退出。 加上 condition: service_healthy,compose 才会去等健康检查—— 那才是你真正想要的。 depends_on ไม่ได้แปลว่าอย่างที่คุณคิด ลำพังมันรอแค่ให้คอนเทนเนอร์ฐานข้อมูล เริ่มทำงาน ไม่ได้รอให้ PostgreSQL ข้างในพร้อมรับการเชื่อมต่อ Postgres ใช้เวลาอีกไม่กี่วินาที แอปจึงเชื่อมต่อไม่ติด โยน ECONNREFUSED แล้วปิดตัวลง การใส่ condition: service_healthy ทำให้ compose รอ healthcheck แทน ซึ่งคือสิ่งที่คุณตั้งใจจริง ๆ
Two guards, on purpose. The healthcheck fixes it inside compose. The retry loop in the backend fixes it everywhere else — including when somebody pulls the image from Docker Hub and runs it with plain docker run, where no compose file exists to help them. 刻意做了两道防线。健康检查解决 compose 里的问题; 后端的重试循环解决其他所有场景——包括别人从 Docker Hub 拉下镜像、 直接用 docker run 跑的时候,那里可没有 compose 文件帮他们。 ป้องกันสองชั้นโดยตั้งใจ healthcheck แก้ปัญหาภายใน compose ส่วนลูป retry ในแบ็กเอนด์แก้ในทุกกรณีที่เหลือ รวมถึงตอนที่มีคนดึงอิมเมจจาก Docker Hub แล้วรันด้วย docker run เปล่า ๆ ซึ่งไม่มีไฟล์ compose มาช่วย

▶️ Run it

Four commands. The first one takes a minute; the rest are instant.

把它跑起来 — 四条命令。第一条要花大约一分钟,其余都是瞬间完成。
รันมันเลย — สี่คำสั่ง คำสั่งแรกใช้เวลาราวหนึ่งนาที ที่เหลือเสร็จทันที
# build the image and start both containers docker compose up --build -d # watch them become healthy docker compose ps # read the backend's log — you want "[web] listening on 3000" docker compose logs -f web # then open http://localhost:8080

Prove it actually reached PostgreSQL

Do not trust the screen. Ask the database directly.

证明数据真的进了 PostgreSQL。别相信界面,直接去问数据库。
พิสูจน์ว่าข้อมูลไปถึง PostgreSQL จริง อย่าเชื่อหน้าจอ ให้ถามฐานข้อมูลตรง ๆ
# add an event through the API curl -X POST http://localhost:8080/api/events \ -H "Content-Type: application/json" \ -d '{"title":"Teacher meeting","date":"2026-08-26","time":"09:30"}' # now look inside the database container itself docker compose exec db psql -U calendar -d calendar \ -c "SELECT id, title, event_date FROM events ORDER BY id;"
id | title | event_date ----+-------------------------+------------ 1 | Teacher meeting (moved) | 2026-08-27 2 | Songkran planning | 2026-08-14 4 | Parents evening | 2026-08-11 (3 rows)
That output is real. Row 4 was created by clicking + on a day in the browser and typing into the dialog — browser click, to fetch, to Express, to PostgreSQL, then read back with psql from inside the database container. Row 3 was the injection attempt, deleted afterwards to prove DELETE returns 204. 这段输出是真的。第 4 行是在浏览器里点某一天的 +、 在弹窗里输入而产生的——浏览器点击 → fetch → Express → PostgreSQL, 再用 psql 从数据库容器内部读回来。第 3 行是那次注入尝试,之后被删掉, 用来验证 DELETE 返回 204 ผลลัพธ์นี้ของจริง แถวที่ 4 เกิดจากการคลิก + บนวันหนึ่งในเบราว์เซอร์แล้วพิมพ์ลงในกล่องโต้ตอบ คือคลิกในเบราว์เซอร์ ไปที่ fetch ไปที่ Express ไปถึง PostgreSQL แล้วอ่านกลับด้วย psql จากในคอนเทนเนอร์ฐานข้อมูล ส่วนแถวที่ 3 คือความพยายามทำ injection ซึ่งถูกลบทีหลังเพื่อพิสูจน์ว่า DELETE คืนค่า 204

Stopping

docker compose down # stop, KEEP the data docker compose down -v # stop and DELETE the data volume
-v is the destructive one. It removes the named volume, and every event goes with it. There is no confirmation prompt. -v 是那个有破坏性的选项。它会删除命名卷, 所有事件都会跟着消失。而且不会有任何二次确认。 -v คือตัวที่ทำลายข้อมูล มันลบ named volume และกิจกรรมทั้งหมดจะหายไปด้วย โดยไม่มีข้อความยืนยันใด ๆ

🚢 Push it to Docker Hub

One image, two processor architectures, one command.

推送到 Docker Hub — 一个镜像、两种处理器架构、一条命令。
ส่งขึ้น Docker Hub — หนึ่งอิมเมจ สองสถาปัตยกรรมซีพียู หนึ่งคำสั่ง

This image is published and public. You can run it right now without building anything:

这个镜像已经发布并且是公开的。你现在就可以直接运行,不需要自己构建:
อิมเมจนี้เผยแพร่แล้วและเป็นสาธารณะ คุณรันได้ทันทีโดยไม่ต้องบิลด์เอง
docker run -d --name cal-db --network calnet \ -e POSTGRES_USER=calendar -e POSTGRES_PASSWORD=calendar \ -e POSTGRES_DB=calendar postgres:17-alpine docker run -d --name cal-web --network calnet \ -e PGHOST=cal-db -p 8080:3000 \ ddtraveller/calendar-lab:1.0.0

Publishing your own

# 1. log in docker login # 2. teach your machine to build for other processors docker run --privileged --rm tonistiigi/binfmt --install arm64 # 3. a builder that can use that emulation docker buildx create --name calbuilder --driver docker-container --use docker buildx inspect calbuilder --bootstrap # must list linux/arm64 # 4. build BOTH architectures and push in one step docker buildx build --builder calbuilder \ --platform linux/amd64,linux/arm64 \ -t youruser/calendar-lab:latest \ -t youruser/calendar-lab:1.0.0 \ --push .

Check what you actually published — a multi-arch image is a list of images:

检查你到底发布了什么——多架构镜像其实是一个镜像列表
ตรวจสอบว่าคุณเผยแพร่อะไรไปจริง ๆ เพราะอิมเมจหลายสถาปัตยกรรมคือรายการของอิมเมจ
docker manifest inspect youruser/calendar-lab:latest
linux/amd64 sha256:c2963d67e94d4... linux/arm64 sha256:639f6a964ea1b...
Two traps when publishing. First, piping a buildx build into tail or head hides its exit code — the last lines can look perfectly happy while the build actually failed. Capture the status explicitly. Second, if buildx inspect does not list linux/arm64, the emulator is not registered for that builder; a builder created before you installed binfmt will not pick it up, so create a fresh one. 发布时的两个坑。第一,把 buildx 构建的输出管道给 tailhead 会掩盖它的退出码——最后几行看着一切正常, 实际上构建已经失败了。要显式地捕获状态码。第二,如果 buildx inspect 没列出 linux/arm64,说明该 builder 没注册模拟器; 在你安装 binfmt 之前创建的 builder 不会自动获得它,所以要新建一个。 กับดักสองข้อตอนเผยแพร่ ข้อแรก การไพป์ผลลัพธ์ของ buildx ไปยัง tail หรือ head จะบดบัง exit code บรรทัดท้าย ๆ อาจดูเรียบร้อยทั้งที่บิลด์ล้มเหลวไปแล้ว ให้เก็บค่าสถานะอย่างชัดเจน ข้อสอง ถ้า buildx inspect ไม่แสดง linux/arm64 แปลว่า builder นั้นยังไม่มีตัวจำลอง builder ที่สร้างก่อนติดตั้ง binfmt จะไม่ได้รับมันมา ต้องสร้างใหม่
Both traps were hit while building this page. The first builder on the machine reported only linux/amd64 even after binfmt installed qemu-aarch64 successfully. A brand new builder immediately reported linux/arm64, and the push produced both architectures. 做这一页的时候这两个坑都踩到了。即使 binfmt 已经成功安装了 qemu-aarch64,机器上原有的 builder 仍然只报告 linux/amd64。 新建一个 builder 后立刻就报告了 linux/arm64,推送也顺利产出了两种架构。 เจอกับดักทั้งสองข้อตอนทำหน้านี้ แม้ binfmt จะติดตั้ง qemu-aarch64 สำเร็จแล้ว แต่ builder เดิมบนเครื่องก็ยังรายงานแค่ linux/amd64 พอสร้าง builder ใหม่ มันรายงาน linux/arm64 ทันที และการ push ก็ได้ครบทั้งสองสถาปัตยกรรม

🧯 What will go wrong

Symptom, cause, fix. Every row here happened to somebody.

会出什么问题 — 症状、原因、解法。这里每一行都真的发生过。
อะไรจะพังบ้าง — อาการ สาเหตุ วิธีแก้ ทุกแถวเคยเกิดขึ้นกับใครสักคน
SymptomCauseFix
ECONNREFUSED then the web container exits App started before Postgres was accepting connections condition: service_healthy plus the retry loop
EAI_AGAIN in the log DNS could not resolve db — that container does not exist yet Same fix; it resolves once the container is up
Edited init.sql, nothing changed Init scripts run only on a first-ever empty volume docker compose down -v, or migrate from the app
Events show a day early or late toISOString() converted a local day to UTC Build YYYY-MM-DD from local parts
All events vanished Someone ran docker compose down -v Nothing. The volume is gone. Back up first
port is already allocated Something else on the host owns 8080 Change the left half: "8081:3000"
Code change not visible after rebuild Browser cached the old app.js Hard reload, or add ?v=2 to the URL
docker stop takes ten seconds PID 1 ignored SIGTERM tini as ENTRYPOINT and a SIGTERM handler
Push succeeded but the image is amd64 only Builder had no arm64 emulation registered Install binfmt, then create a new builder
Build "succeeded" but nothing was pushed Piping into tail masked a non-zero exit code Capture the exit status explicitly

📌 Points to remember

  1. A container is one process in a box. When the process stops, the box is gone.
  2. Anything that must survive lives in a volume, not in the container.
  3. depends_on waits for a container to start, not for the service to be ready.
  4. Init scripts run once, on an empty volume, and never mention it again.
  5. The service name is the hostname. PGHOST=db is all the addressing you need.
  6. A database with no published ports cannot be reached from the internet.
  7. Pass values as $1, never by gluing them into SQL.
  8. Copy package.json before the source, or you reinstall npm on every edit.
  9. Never format a calendar day with toISOString().
  10. Check what you published with docker manifest inspect — believe the manifest, not the build log.
要记住的要点 — 容器是盒子里的一个进程;要留下来的东西放进数据卷; depends_on 只等启动不等就绪;初始化脚本只在空卷上跑一次; 服务名就是主机名;不开放端口的数据库外网碰不到;用 $1 传值而不是拼接 SQL; 先复制 package.json;日历日期绝不用 toISOString(); 用 docker manifest inspect 核实你到底发布了什么。
ข้อที่ต้องจำ — คอนเทนเนอร์คือโปรเซสเดียวในกล่อง สิ่งที่ต้องอยู่รอดให้เก็บใน volume depends_on รอแค่ให้เริ่ม ไม่ได้รอให้พร้อม สคริปต์ init รันครั้งเดียวบน volume ว่าง ชื่อเซอร์วิสคือชื่อโฮสต์ ฐานข้อมูลที่ไม่เปิดพอร์ตเข้าถึงจากอินเทอร์เน็ตไม่ได้ ส่งค่าด้วย $1 อย่าต่อสตริงเข้าไปใน SQL คัดลอก package.json ก่อน อย่าใช้ toISOString() กับวันในปฏิทิน และตรวจสิ่งที่เผยแพร่ด้วย docker manifest inspect