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:

bash
$ mkdir -p ~/katalog/init && cd ~/katalog

Tiga berkas berikut adalah seluruh isi folder itu. Berkas pertama menciptakan skema:

sql
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:

sql
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:

sh
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;
SQL

Mari kita telaah keputusan-keputusan pada ketiga berkas itu:

  1. id SERIAL PRIMARY KEY — selama ini tabel produk kita 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.
  2. Awalan angka pada nama berkas — 001_create.sql menciptakan tabel, 002_seed.sql mengisinya, 003_user.sh mengatur administrasinya; alfabetis menjalankannya dalam urutan yang kita maksudkan, dan nomor besar selalu bisa disisipkan di antaranya (004_, 005_) tanpa menggeser yang lama.
  3. set -e pada 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.
  4. -v ON_ERROR_STOP=1 pada psql — penjaga yang sama untuk perintah-perintah SQL di dalam skrip: galat pertama menghentikan eksekusi, bukan dilompati.
  5. "$POSTGRES_USER" dan "$POSTGRES_DB" — variabel environment yang kita suntik lewat -e tersedia penuh saat skrip berjalan, jadi skrip tidak perlu mengulang nilai katalog secara 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:

bash
$ docker rm -f db && docker volume rm db-data

Output yang Dihasilkan:

text
db
db-data

Kini perintah inti chapter ini — docker run yang sama dengan chapter pertama section ini, ditambah satu bind mount baru:

bash
$ 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-alpine

Output yang Dihasilkan:

text
e6af3d172073746ef41475acdafffdec7904d731e2fe40d0d6b671f291694e4b

Mari kita telaah dua bagian yang baru dan satu kebiasaan lama:

  1. -v $(pwd)/init:/docker-entrypoint-initdb.d:ro — bind mount dari folder init/ 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.
  2. :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.
  3. Volume db-data tetap 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:

bash
$ docker logs db

Output yang Dihasilkan:

text
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 connections

Baris-baris initdb bagian awal dipotong agar ringkas. Mari kita telaah sisi baru dari log yang sudah akrab itu:

  1. running /docker-entrypoint-initdb.d/001_create.sql — entry point mengumumkan setiap berkas yang dijalankan, berurutan persis seperti alfabetisnya; tiga baris running ini adalah bukti skrip kita benar-benar dieksekusi, bukan sekadar dipasang.
  2. 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 perintah INSERT.
  3. 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:

bash
$ docker exec db psql -U katalog -d katalog -c "SELECT * FROM produk;"

Output yang Dihasilkan:

text
 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:

bash
$ 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:

text
 count
-------
     3
(1 row)

ERROR:  permission denied for table produk

Inisialisasi 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:

bash
$ docker restart db
$ docker logs db --tail 3

Output yang Dihasilkan:

text
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 connections

Skipping 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:

yaml
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:

  1. db memasang ./init seperti 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.
  2. migrate memakai image aplikasi dengan command yang ditimpa — node migrate.js menggantikan server.js lewat pola Chapter *Command Override`; berkas migrasi ikut di-COPY ke image bersama aplikasi, sehingga versi skema dan versi kode selalu berumur bersama.
  3. Gerbang service_healthy — migrasi baru berjalan setelah pg_isready lulus, probe yang kita kenal dari Chapter Healthcheck dan Service Readiness dan yang menjaga DDL dari menabrak server yang masih bangun tidur.
  4. Gerbang service_completed_successfully pada web — 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 — urutan db Healthy → migrate Exited (0) → web Started adalah tarian yang sama, kini dengan initdb sebagai babak pembuka.
  5. db-data di Compose terwujud sebagai katalog_db-data — prefix nama project tetap berlaku pada volume Compose, jadi objek ini berbeda dari volume db-data milik docker run kita; 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:

Aspekinitdb (init/)Migrasi runtime (migrasi/)
Ditulis kapansebelum container pertama menyalasetiap kali skema berkembang
Dijalankan kapanhanya saat volume kosongsetiap kali pelari migrasi berjalan
Bisa berulang?tidak — sekali seumur volumeya — hanya versi yang belum tercatat
Yang dieksekusiseluruh berkas .sql/.sh/.sql.gz urut alfabetishanya delta yang belum diterapkan
Peran terbaikskema 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 #