Penutup chapter sebelumnya menyerahkan persoalan yang belum selesai: sepanjang section ini kita menanam tabel dan baris dengan tangan sendiri — membuka sesi psql, mengetik CREATE TABLE, lalu menyimpan Kopi Torabika satu per satu. Cara itu cocok untuk eksplorasi, tetapi runtuh pada saat pertama kali orang lain harus mengulangnya: Siti baru bergabung ke proyek katalog, meng-clone folder proyek dari Budi, dan menjalankan container database di mesinnya — yang ia dapatkan adalah database kosong tanpa tabel, karena seluruh ritual kita hidup hanya di ingatan dan riwayat terminal. Skema yang tidak bisa direproduksi bukan skema, melainkan cerita lisan. Chapter ini menggantikan cerita lisan itu dengan mekanisme resmi image postgres: folder init/ yang dipasang ke /docker-entrypoint-initdb.d, dijalankan otomatis tepat ketika cluster lahir, lalu pola service one-shot di Compose untuk perubahan yang datang setelahnya.
Pintu Resmi: docker-entrypoint-initdb.d #
Image resmi postgres — dan saudaranya mysql serta mariadb — menyimpan satu konvensi yang mengubah tarian inisialisasi manual menjadi berkas yang di-commit bersama proyek. Entry point ketiganya memeriksa direktori data sebelum bekerja; bila direktori itu kosong, setelah initdb selesai dan variabel POSTGRES_* diproses, entry point menelusuri isi folder /docker-entrypoint-initdb.d dan menjalankannya satu per satu. Tiga jenis berkas dikenali: .sql dijalankan lewat psql, .sql.gz didekompresi lebih dulu lalu dijalankan, dan .sh dijalankan sebagai skrip shell. Redis tidak punya mekanisme semacam ini — masuk akal, karena penyimpan kunci-nilai memang tidak punya skema untuk diinisialisasi.
Kabar pentingnya ada pada urutan: berkas dijalankan berurutan sesuai alfabetis nama berkasnya, dan seluruh tarian hanya terjadi saat volume masih kosong. Alur lengkapnya terpetakan berikut:
Diagram di atas merangkum satu-satunya percabangan yang diputus entry point setiap kali container dinyalakan: jalur kiri hanya tersedia sekali seumur volume, sedangkan jalur kanan adalah kehidupan normal volume yang sudah berisi cluster — keputusan yang sama dengan kalimat Skipping initialization yang kita saksikan pada Chapter Persistensi Database. Urutan alfabetis itulah alasan konvensi penamaan berawalan angka lahir. Bayangkan bila berkas kita bernama data_awal.sql dan buat_tabel.sql: alfabetis menjalankan data_awal.sql lebih dulu, dan PostgreSQL menjawabnya dengan ERROR: relation "produk" does not exist karena tabelnya belum diciptakan. Awalan 001_, 002_, 003_ memaksa alfabetis tunduk pada logika kita: skema dulu, data kemudian, administrasi terakhir.
Menyiapkan Folder init #
Kita bekerja di folder proyek katalog di mesin Budi, dan seluruh berkas inisialisasi ditempatkan pada folder init/ di dalamnya:
$ mkdir -p ~/katalog/init && cd ~/katalogTiga berkas berikut adalah seluruh isi folder itu. Berkas pertama menciptakan skema:
CREATE TABLE produk (
id SERIAL PRIMARY KEY,
nama TEXT NOT NULL,
harga INTEGER NOT NULL
);Berkas kedua menanam data awal — termasuk penghuni tertua volume kita:
INSERT INTO produk (nama, harga) VALUES
('Kopi Torabika', 15000),
('Kopi Robusta Lampung', 65000),
('Gula Aren Cair', 45000);Berkas ketiga adalah skrip shell yang menciptakan role khusus pelaporan — baca saja, tanpa izin menulis:
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<'SQL'
CREATE ROLE pelaporan LOGIN PASSWORD 'rahasia123';
GRANT SELECT ON produk TO pelaporan;
SQLMari kita telaah keputusan-keputusan pada ketiga berkas itu:
id SERIAL PRIMARY KEY— selama ini tabelprodukkita ditulis dadakan dengan dua kolom; kali ini skema dirancang sungguhan dengan primary key, karena kunci itulah yang membuat baris punya identitas yang bisa dirujuk aplikasi.- Awalan angka pada nama berkas —
001_create.sqlmenciptakan tabel,002_seed.sqlmengisinya,003_user.shmengatur administrasinya; alfabetis menjalankannya dalam urutan yang kita maksudkan, dan nomor besar selalu bisa disisipkan di antaranya (004_,005_) tanpa menggeser yang lama. set -epada skrip shell — kegagalan sekecil apa pun menghentikan skrip dengan exit code bukan nol, sehingga inisialisasi yang setengah jadi tidak pernah diam-diam dianggap sukses; container akan mati nyaring dan masalahnya terlihat di log.-v ON_ERROR_STOP=1pada psql — penjaga yang sama untuk perintah-perintah SQL di dalam skrip: galat pertama menghentikan eksekusi, bukan dilompati."$POSTGRES_USER"dan"$POSTGRES_DB"— variabel environment yang kita suntik lewat-etersedia penuh saat skrip berjalan, jadi skrip tidak perlu mengulang nilaikatalogsecara manual — ganti variabelnya, dan skrip tetap benar.
Menjalankan dan Membaca Log Inisialisasi #
Volume db-data saat ini masih berisi cluster lama dengan tabel produk dan peserta. Initdb tidak akan menyentuhnya — jadi kita jalankan ritual reset bersih yang kita warisi dari Chapter Persistensi Database, dengan sadar meletakkan tabel-tabel lama itu sebagai korban: tabel produk akan lahir ulang lebih rapi, sementara peserta yang memang hanya milik percobaan ikut lenyap:
$ docker rm -f db && docker volume rm db-dataOutput yang Dihasilkan:
db
db-dataKini perintah inti chapter ini — docker run yang sama dengan chapter pertama section ini, ditambah satu bind mount baru:
$ docker run -d --name db \
-e POSTGRES_USER=katalog \
-e POSTGRES_PASSWORD=rahasia123 \
-e POSTGRES_DB=katalog \
-v db-data:/var/lib/postgresql/data \
-v $(pwd)/init:/docker-entrypoint-initdb.d:ro \
postgres:18-alpineOutput yang Dihasilkan:
e6af3d172073746ef41475acdafffdec7904d731e2fe40d0d6b671f291694e4bMari kita telaah dua bagian yang baru dan satu kebiasaan lama:
-v $(pwd)/init:/docker-entrypoint-initdb.d:ro— bind mount dari folderinit/di direktori kerja host ke alamat konvensi image; shell mengembangkan$(pwd)menjadi path absolut sebelum daemon melihatnya, sehingga yang dipasang benar-benar folder milik proyek kita. Di Git Bash pola ini berjalan baik, sementara di PowerShell Siti cukup menggantinya dengan${PWD}— kebiasaan path lintas sistem yang sudah kita kelola sejak Chapter Bind Mount.:ro— folder inisialisasi dipasang read-only: entry point hanya perlu membacanya, dan penguncian ini mencegah skrip yang tidak sengaja menulis balik ke folder proyek — pola yang kita bedah pada Chapter Mount Read-Only.- Volume
db-datatetap pada/var/lib/postgresql/data— inisialisasi otomatis dan persistensi tetap bekerja pada titik yang sama;init/hanya mengisi momen kelahiran cluster, bukan mengganti rumahnya.
Log startup kini menceritakan pekerjaan yang sebelumnya kita kerjakan dengan tangan:
$ docker logs dbOutput yang Dihasilkan:
The files belonging to this database system will be owned by user "katalog".
This user must also own the server process.
...
initdb: warning: enabling "trust" authentication for local connections only.
You can change this by editing pg_hba.conf or using the option -A (or
--auth-local and --auth-host) the next time you run initdb.
ALTER ROLE
running /docker-entrypoint-initdb.d/001_create.sql
CREATE TABLE
running /docker-entrypoint-initdb.d/002_seed.sql
INSERT 0 3
running /docker-entrypoint-initdb.d/003_user.sh
CREATE ROLE
GRANT
init process complete; ready for start up.
2026-10-13 22:02:41.302 UTC [1] LOG: starting PostgreSQL 18.4 (Debian 18.4-1.pgdg130+1) on x86_64-pc-linux-musl, compiled by gcc (Alpine 14.2.0) 14.2.0, 64-bit
2026-10-13 22:02:41.303 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
2026-10-13 22:02:41.414 UTC [1] LOG: database system is ready to accept connectionsBaris-baris initdb bagian awal dipotong agar ringkas. Mari kita telaah sisi baru dari log yang sudah akrab itu:
running /docker-entrypoint-initdb.d/001_create.sql— entry point mengumumkan setiap berkas yang dijalankan, berurutan persis seperti alfabetisnya; tiga barisrunningini adalah bukti skrip kita benar-benar dieksekusi, bukan sekadar dipasang.CREATE TABLE,INSERT 0 3,CREATE ROLE,GRANT— jawaban-jawaban server untuk isi tiap berkas, sama persis dengan yang dulu kita ketik manual; tiga baris produk masuk lewat satu perintahINSERT.init process complete; ready for start up.— batas antara babak inisialisasi dan babak layanan: seluruh berkas selesai sebelum server sungguhan menyala sebagai proses utama, sehingga container tidak pernah menerima klien dengan skema setengah jadi.
Bukti akhirnya tetap datang dari database itu sendiri:
$ docker exec db psql -U katalog -d katalog -c "SELECT * FROM produk;"Output yang Dihasilkan:
id | nama | harga
----+---------------------+------
1 | Kopi Torabika | 15000
2 | Kopi Robusta Lampung | 65000
3 | Gula Aren Cair | 45000
(3 rows)Dan role pelaporan pun bisa diuji langsung — membaca diizinkan, menulis ditolak:
$ docker exec db psql -U pelaporan -d katalog -c "SELECT count(*) FROM produk;"
$ docker exec db psql -U pelaporan -d katalog -c "INSERT INTO produk (nama, harga) VALUES ('Kopi Maling', 999);"Output yang Dihasilkan:
count
-------
3
(1 row)
ERROR: permission denied for table produkInisialisasi Hanya Terjadi Sekali #
Perilaku inisialisasi satu kali itu perlu dipatri sekarang, sebelum berubah menjadi kejutan di kemudian hari:
Bukti kecilnya cukup satu siklus restart. Tambahkan satu baris pada 002_seed.sql sekarang, lalu restart container — berkas itu tidak akan pernah dibaca ulang:
$ docker restart db
$ docker logs db --tail 3Output yang Dihasilkan:
db
PostgreSQL Database directory appears to be a database; Skipping initialization
2026-10-13 22:06:03.214 UTC [1] LOG: database system is ready to accept connectionsSkipping initialization — kalimat yang sama dengan pengadilan docker rm -f pada Chapter *Persistensi Database. Baris baru di 002_seed.sql` kita tidak pernah dibaca ulang, karena entry point memutuskan percabangan “volume kosong?” dengan “tidak” begitu cluster terdeteksi. Batu sandungan ini sebenarnya berita baik: initdb tidak pernah bisa menimpa data hidup. Tapi konsekuensinya tegas — mekanisme ini hanya mengurus kelahiran, bukan pertumbuhan.
Batas initdb dan Service One-Shot Migrasi #
Volume db-data malam ini baru berisi tiga baris produk — tetapi bayangkan volume yang sama dua bulan ke depan: penuh data transaksi berharga yang tidak boleh direset, sementara aplikasi membutuhkan kolom baru untuk stok. Initdb jelas bukan jalannya, karena berkas init hanya hidup pada volume kosong. Di titik inilah pembagian kerja yang sehat mulai terlihat: initdb untuk hari pertama, migrasi untuk hari-hari berikutnya — dan migrasi berarti menjalankan berkas SQL setelah database hidup. Di dunia Compose, pekerjaan itu diberikan kepada service yang berjalan sekali lalu selesai:
name: katalog
services:
db:
image: postgres:18-alpine
environment:
- POSTGRES_USER=katalog
- POSTGRES_PASSWORD=rahasia123
- POSTGRES_DB=katalog
volumes:
- db-data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
migrate:
build: .
image: katalog-web:dev
command: ["node", "migrate.js"]
environment:
- DATABASE_URL=postgres://katalog:rahasia123@db:5432/katalog
depends_on:
db:
condition: service_healthy
web:
build: .
image: katalog-web:dev
environment:
- DATABASE_URL=postgres://katalog:rahasia123@db:5432/katalog
ports:
- "8080:3000"
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
db-data:Mari kita telaah keputusan-keputusan penting pada berkas di atas:
dbmemasang./initseperti pola docker run kita — pada mesin baru seperti milik Siti, volume Compose masih kosong, jadi initdb menciptakan skema dasar dan data awal; pada mesin yang volumenya sudah tua, folder ini diam dilewati.migratememakai image aplikasi dengancommandyang ditimpa —node migrate.jsmenggantikanserver.jslewat pola Chapter *Command Override`; berkas migrasi ikut di-COPY ke image bersama aplikasi, sehingga versi skema dan versi kode selalu berumur bersama.- Gerbang
service_healthy— migrasi baru berjalan setelahpg_isreadylulus, probe yang kita kenal dari Chapter Healthcheck dan Service Readiness dan yang menjaga DDL dari menabrak server yang masih bangun tidur. - Gerbang
service_completed_successfullypadaweb— aplikasi baru menyala setelah container migrate selesai dengan exit code 0; bila migrasi gagal, aplikasi tidak pernah lahir, dan kegagalan berhenti di tempat yang tepat. Koreografi lengkap gerbang-gerbang ini sudah kita saksikan hidup pada stack toko di Chapter Startup dan Dependency — urutandb Healthy → migrate Exited (0) → web Startedadalah tarian yang sama, kini dengan initdb sebagai babak pembuka. db-datadi Compose terwujud sebagaikatalog_db-data— prefix nama project tetap berlaku pada volume Compose, jadi objek ini berbeda dari volumedb-datamilikdocker runkita; dua dunia itu hanya bertemu lewat dump SQL, bukan lewat nama — aturan yang kita pegang sejak Chapter Volume dan Bind Mount.
Kapan initdb, Kapan Migrasi #
Kedua mekanisme itu bukan pesaing, melainkan pembagian shift. Tabel berikut merangkumninya:
| Aspek | initdb (init/) | Migrasi runtime (migrasi/) |
|---|---|---|
| Ditulis kapan | sebelum container pertama menyala | setiap kali skema berkembang |
| Dijalankan kapan | hanya saat volume kosong | setiap kali pelari migrasi berjalan |
| Bisa berulang? | tidak — sekali seumur volume | ya — hanya versi yang belum tercatat |
| Yang dieksekusi | seluruh berkas .sql/.sh/.sql.gz urut alfabetis | hanya delta yang belum diterapkan |
| Peran terbaik | skema dasar dan data awal (seed) | evolusi skema di atas data yang hidup |
Mesin katalog kita kini melahirkan skema dan data awal secara otomatis, dan pola migrasi menunggu di depan pintu untuk perubahan berikutnya. Namun ada satu wilayah yang belum tersentuh mekanisme mana pun: volume db-data itu sendiri masih satu-satunya rumah bagi data — dan docker volume rm yang kita jalankan di awal chapter ini membuktikan betapa mudahnya rumah itu diratakan tanpa sidang. Sebelum kepercayaan penuh diberikan kepada satu disk, data berharga menuntut salinan yang bisa dibawa ke mana pun: berkas dump pg_dump dan mysqldump yang lahir di host, bukan di dalam container. Chapter Backup Database membangun ritual itu langkah demi langkah.
Referensi #
- PostgreSQL — Docker Hub
— dokumentasi image resmi: perilaku
/docker-entrypoint-initdb.d, jenis berkas yang dijalankan, dan syarat volume kosong. - docker container run — CLI Reference
— flag
-vuntuk volume dan bind mount$(pwd)/initdengan mode:ro. - Bind Mounts — mekanisme bind mount folder proyek ke dalam container.
- Compose File Reference — Services
— kunci
depends_ondengan kondisiservice_healthydanservice_completed_successfully.