Web Artisan

Course · Docker

Belajar Docker

Memahami Docker sebagai packaging dan runtime aplikasi backend: image, container, Dockerfile multi-stage, networking, volume, dan Compose untuk menjalankan stack Go yang konsisten dari laptop sampai production.

Read ~95 menit baca

Course · Docker

Belajar Docker
Container untuk Backend Developer

Docker mengemas aplikasi beserta seluruh runtime-nya menjadi image yang berjalan sama di mana saja, dari laptop, CI, staging, sampai production.

Container runtimeCompose stack lokal~95 menit baca
01

Kenapa Backend Developer Perlu Docker

Dari "works on my machine" ke runtime yang konsisten

Docker mengubah pertanyaan “kok jalan di laptopku tapi mati di server” menjadi tidak relevan, karena yang dikirim bukan kode mentah melainkan seluruh runtime yang sudah jadi.

Setiap backend punya ketergantungan tak kasat mata: versi Go, library sistem, variabel lingkungan, sertifikat CA, hingga timezone. Di laptopmu semuanya pas, di server staging sedikit berbeda, dan binary yang sama tiba-tiba gagal. Inilah inti “works on my machine”: runtime tidak pernah benar-benar sama antar mesin. Docker menyelesaikannya dengan membungkus aplikasi beserta seluruh runtime-nya ke dalam satu image yang immutable, lalu menjalankan image itu sebagai container di mana saja secara identik.

Penting dipahami sejak awal: container bukan VM ringan. VM menjalankan kernel dan OS tamu penuh di atas hypervisor, berat dan lambat boot. Container berbagi kernel host, dan isolasi antar proses dicapai lewat fitur kernel Linux: namespaces (memberi proses pandangan terpisah atas PID, jaringan, mount, hostname) dan cgroups (membatasi CPU dan memori). Jadi container itu cuma proses biasa di host yang “dipagari”, bukan sistem operasi tersendiri. Itu sebabnya ia start dalam milidetik dan ringan.

AspekVirtual MachineContainer
IsolasiOS tamu penuh + kernel sendiriProses terisolasi, berbagi kernel host
BootDetik sampai menitMilidetik
UkuranGigabyteMegabyte
OverheadHypervisorNyaris nol
📦Analogi: kontainer pengapalan

Sebelum kontainer standar, barang dimuat manual dan tiap pelabuhan menanganinya beda. Kontainer baja berukuran seragam membuat kapal, truk, dan derek tak peduli isinya, sama seperti image yang jalan identik di laptop maupun server.

🌉Jembatan: node_modules dan Laravel Sail

Pernah npm install menghasilkan dependensi beda antar mesin karena versi Node berbeda, atau pakai Laravel Sail yang sebenarnya Docker? Image membawa runtime Go, library sistem, dan config sekaligus, jadi tidak ada lagi langkah “install dulu di server”.

Manfaat utamanya adalah reproducibility: image yang sama persis mengalir dari laptop developer ke CI, ke staging, lalu ke production tanpa rebuild ulang yang bisa menghasilkan hasil berbeda. Bonusnya dependency isolation: layanan A butuh PostgreSQL 16 dan layanan B butuh 17, keduanya hidup berdampingan tanpa bentrok di host.

flowchart LR
  D[Dev<br/>laptop] -->|image yang sama| CI[CI<br/>pipeline]
  CI -->|image yang sama| S[Staging]
  S -->|image yang sama| P[Production]

Satu image, banyak tahap. Artefak yang diuji di CI adalah artefak yang berjalan di production.

Ayo buktikan dengan satu perintah. Jalankan docker run hello-world. Yang terjadi di balik layar: Docker mencari image hello-world di cache lokal, tidak menemukannya lalu menarik (pull) dari Docker Hub, membuat container baru dari image itu, menjalankan proses di dalamnya yang mencetak pesan, lalu proses selesai dan container berhenti.

Terminal
docker run hello-world # Unable to find image 'hello-world:latest' locally # latest: Pulling from library/hello-world # Hello from Docker!
📝Yang baru kamu pelajari

Docker adalah mekanisme packaging plus runtime aplikasi: ia membungkus aplikasi beserta runtime-nya menjadi image, lalu menjalankannya sebagai proses terisolasi yang konsisten di mesin mana pun.

02

Mental Model: Image, Container, Registry

Plus Docker Engine, daemon, dan CLI

Tiga kata kunci Docker, image, container, dan registry, akan terus muncul, jadi mari kunci maknanya dulu sebelum menyentuh perintah yang lebih dalam.

Sebuah image adalah template yang immutable: ia membekukan sebuah filesystem lengkap berisi dependensi, konfigurasi, dan metadata (perintah default, port, environment). Image tidak pernah berubah setelah dibuat. Sebuah container adalah proses yang berjalan dari sebuah image, dengan satu lapisan writable miliknya sendiri di atas lapisan image yang read-only. Sebuah registry adalah tempat menyimpan dan berbagi image, seperti Docker Hub (docker.io), GitHub Container Registry (ghcr.io), atau AWS ECR.

🌉Jembatan: class vs instance

Image itu seperti class atau package npm yang terpasang: definisi diam. Container itu instance dari class tersebut, atau proses node yang berjalan dari package itu. Satu definisi, banyak proses hidup.

Image diidentifikasi lewat tag dan digest. Tag seperti nginx:1.27 adalah label yang ramah manusia tapi mutable, pemiliknya bisa menggeser 1.27 ke build yang berbeda kapan saja. Digest seperti nginx@sha256:abc... adalah sidik jari kriptografis dari isi image, immutable dan menjamin kamu menarik bit yang persis sama. Untuk deploy yang reproducible, tag dipakai untuk keterbacaan, digest untuk kepastian.

KonsepSifatAnalogi
ImageTemplate immutableClass / package
ContainerProses + writable layerInstance / proses berjalan
RegistryPenyimpanan & distribusinpm registry / Packagist

Secara arsitektur, perintah docker yang kamu ketik berasal dari Docker CLI. CLI tidak melakukan pekerjaan berat; ia mengirim permintaan ke Docker daemon (dockerd), bagian inti dari Docker Engine. Daemon inilah yang benar-benar mengelola image, container, network, dan volume, menarik image dari registry, dan menjalankan proses container.

🌉Jembatan: CLI seperti artisan atau npm script

Sama seperti php artisan atau npm run hanyalah penyetir yang memerintahkan mesin di belakangnya, docker CLI menyetir dockerd. Yang melakukan pekerjaan nyata adalah daemon, bukan baris perintahmu.

flowchart LR
  CLI[docker CLI] -->|API request| D[dockerd<br/>Docker Engine]
  D --- IM[Images]
  D --- CT[Containers]
  D --- NW[Networks]
  D --- VOL[Volumes]
  REG[(Registry)] <-->|pull / push| D

Alur perintah Docker. CLI mengirim ke daemon; daemon mengelola objek lokal dan bertukar image dengan registry.

Mari rasakan lifecycle-nya. docker pull nginx menarik image dari Docker Hub. docker images menampilkan image yang tersimpan lokal. docker run membuat lalu menjalankan container dari image. docker ps menampilkan container yang sedang berjalan, sementara docker ps -a menampilkan semua container termasuk yang sudah berhenti.

Terminal
docker pull nginx # tarik image ke cache lokal docker images # daftar image lokal docker run -d nginx # buat & jalankan container (detached) docker ps # container yang sedang jalan docker ps -a # semua container, termasuk yang exited
📝Satu image, banyak container

Dari satu image nginx kamu bisa menjalankan puluhan container sekaligus, masing-masing proses terpisah dengan writable layer sendiri. Image tetap satu dan tak berubah.

03

Menjalankan Container Pertama

Foreground vs detached, publish port, name, env

Sebuah container hidup selama satu proses utamanya berjalan, jadi memahami cara mengikat, menamai, dan mengintip proses itu adalah keterampilan harian seorang backend developer.

Container menjalankan satu proses utama (PID 1 di dalamnya). Ketika proses itu keluar, container ikut berhenti. Saat dijalankan foreground, terminalmu menempel ke output proses dan tertahan sampai proses selesai. Dengan flag -d (detached), container berjalan di latar dan terminalmu langsung bebas.

🌉Jembatan: dari npm run dev

npm run dev menahan terminalmu selama server hidup, itu mode foreground. Flag -d pada docker run seperti menjalankan proses yang sama di background, terminal kembali bisa dipakai sementara container tetap melayani.

Agar container bisa diakses dari host, kamu perlu publish port dengan -p host:container. Tanpa ini, port yang dibuka di dalam container tidak terjangkau dari mesinmu. Flag lain yang sering dipakai: --name memberi nama yang mudah diingat (ganti ID heksadesimal acak), -e KEY=val atau --env-file .env menyuntikkan environment variable, dan --rm otomatis menghapus container saat ia berhenti agar tidak menumpuk.

Publish port: -p 8080:80 Browser localhost:8080 Host (laptop) port 8080 Container nginx port 80 HTTP 8080 → 80 Browser menuju port 8080 host, Docker meneruskannya ke port 80 di dalam container.
Publish port. -p 8080:80 memetakan port 8080 host ke 80 container.

Mari jalankan server web sungguhan. Perintah berikut menjalankan nginx secara detached, menamainya web, dan memetakan port 80 container ke 8080 host.

Terminal
docker run -d --name web -p 8080:80 nginx # buka http://localhost:8080 di browser, halaman selamat datang nginx muncul docker logs web # intip output proses di dalam container docker stop web # hentikan container (kirim sinyal ke proses utama) docker rm web # hapus container yang sudah berhenti

Karena container berjalan detached, kamu tidak melihat log-nya langsung di terminal. Di situlah docker logs web berguna: ia mengalirkan apa pun yang ditulis proses utama ke stdout dan stderr, persis output yang akan kamu lihat seandainya menjalankannya foreground.

sequenceDiagram
  participant U as Browser
  participant H as Host :8080
  participant C as Container :80
  U->>H: GET http://localhost:8080
  H->>C: teruskan ke port 80
  C-->>H: respons nginx
  H-->>U: halaman tampil

Jalur permintaan. Host menerima di 8080 lalu meneruskan ke port 80 di dalam container.

⚠️Jebakan: lupa -p

Tanpa -p, proses di dalam container boleh saja mendengarkan di port 80, tetapi port itu tidak terekspos ke host. Browser akan gagal konek, dan banyak pemula mengira aplikasinya rusak padahal hanya kurang publish port.

💡Pakai --rm untuk percobaan singkat

Saat sekadar mencoba sebuah image, tambahkan --rm agar container otomatis terhapus begitu berhenti. Kamu terhindar dari menumpuknya container exited yang harus dibersihkan manual lewat docker rm.

04

Filesystem Container dan Layer

Layer image immutable plus writable layer container

Image Docker bukan satu blok utuh, melainkan tumpukan layer read-only, dan container hanyalah satu lapisan tipis yang bisa ditulis di atasnya.

Setiap instruksi di Dockerfile (FROM, COPY, RUN, …) menghasilkan satu layer baru yang immutable. Layer ini disimpan terpisah, di-hash, dan di-cache. Saat Docker membangun image, ia menumpuk layer-layer itu dari bawah ke atas. Karena immutable, layer yang sama bisa dipakai ulang oleh banyak image yang berbeda, dan tidak pernah berubah setelah dibuat.

Ketika kamu menjalankan docker run, daemon tidak menyalin seluruh image. Ia hanya menambahkan satu writable layer (sering disebut container layer) tepat di atas tumpukan image. Semua tulisan baru, file yang kamu buat, log yang dihasilkan, perubahan konfigurasi, mendarat di layer writable ini, bukan di layer image di bawahnya.

Layer image immutable dan writable layer container writable layer milik container · bisa ditulis app binary COPY /api dependencies go mod download base image distroless / golang Writable layer milik container. Hilang saat container dihapus. Layer image read-only dan immutable. Tiap instruksi Dockerfile = satu layer. Di-cache dan dibagi banyak container.
Layer image dan writable layer. Image bersifat immutable; container menambah satu lapisan writable yang hilang saat container dihapus.

Mekanisme ini disebut copy-on-write (CoW). Selama container hanya membaca file, ia membaca langsung dari layer image yang dibagikan. Begitu container mengubah sebuah file, Docker menyalin file itu ke writable layer lebih dulu, lalu menulis perubahan di salinan tersebut. Layer image asli tidak pernah tersentuh. Inilah kenapa banyak container dari satu image yang sama nyaris tidak memakan disk tambahan: mereka berbagi layer read-only yang sama dan hanya membayar untuk perubahan masing-masing.

🧊Analogi: lembar transparansi di atas peta

Image adalah peta cetak yang permanen; writable layer adalah lembar transparansi di atasnya tempat kamu mencoret-coret. Buang transparansinya, petanya tetap bersih seperti semula.

Konsekuensi penting: menghapus container berarti membuang writable layer-nya, dan semua yang ditulis di sana ikut hilang. Image di bawahnya sama sekali tidak terpengaruh. Ini sengaja: container dirancang sebagai sesuatu yang sekali pakai (ephemeral). Mari buktikan langsung di terminal.

Terminal
docker run -it --name tmp alpine sh # di dalam container: mkdir -p /data && echo "halo skincare" > /data/catatan.txt cat /data/catatan.txt # -> halo skincare exit docker rm tmp # buang container + writable layer-nya docker run -it --name tmp alpine sh cat /data/catatan.txt # -> No such file or directory

File /data/catatan.txt tadi hidup di writable layer container pertama. Saat docker rm tmp menghapus container, layer itu lenyap bersama isinya. Container kedua mulai dari layer image alpine yang bersih, jadi /data kosong lagi. Bedakan ini dari docker rmi alpine yang menghapus image-nya: docker rm membuang instance, docker rmi membuang resepnya.

AksiYang dibuangYang tetap
docker rm tmpWritable layer + data di dalamnyaLayer image (di-cache)
docker rmi alpineLayer image alpineContainer lain yang masih jalan
⚠️Jangan simpan data penting di filesystem container

Data di writable layer hilang permanen saat container dihapus, di-recreate, atau saat deploy versi baru. Database, file upload produk skincare, dan log audit harus keluar dari container lewat volume (Section 10), bukan ditulis ke filesystem-nya.

Soal “ke mana datanya pergi kalau bukan ke container” inilah yang memotivasi volume di Section 10. Untuk sekarang, cukup tanamkan model mentalnya: image = tumpukan layer beku, container = satu lapisan cair di atasnya yang menguap saat container dibuang.

05

Dockerfile: Resep Image

FROM sampai CMD, build context, dan .dockerignore

Dockerfile adalah resep deklaratif: deretan instruksi yang Docker eksekusi dari atas ke bawah untuk merakit sebuah image.

Setiap instruksi menambah satu layer (lihat Section 04), dan urutannya bukan sekadar gaya, ia menentukan seberapa sering cache build kamu meleset. Mari kenali instruksi inti lebih dulu, lalu bahas kenapa urutan itu penting.

InstruksiFungsi
FROMImage dasar tempat semuanya ditumpuk
WORKDIRSet direktori kerja (dan membuatnya bila perlu)
COPYSalin file dari build context ke image
RUNJalankan perintah saat build (compile, install)
ENVSet variabel environment di image
EXPOSEDokumentasi port yang didengarkan (tidak membuka port)
CMDPerintah default saat container dijalankan

Saat kamu menjalankan docker build ., titik di akhir itu adalah build context: seluruh isi folder tersebut di-paket dan dikirim ke daemon Docker sebelum build dimulai. Kalau folder itu berisi .git, node_modules, atau binary lokal berukuran ratusan MB, semuanya ikut terkirim, build jadi lambat dan ada risiko file rahasia (seperti .env) bocor masuk ke image.

flowchart LR
  A[Folder proyek] -->|paket build context| B[Docker daemon]
  B -->|baca Dockerfile| C[Rakit layer]
  C --> D[Image jadi]
  E[.dockerignore] -.->|saring sebelum kirim| A

Build context dikirim ke daemon. .dockerignore menyaring file sebelum apa pun dikirim, jadi konteks tetap ramping.

Solusinya .dockerignore: daftar pola file yang tidak ikut dikirim sebagai build context. Polanya mirip .gitignore, tapi tujuannya berbeda.

🌉Jembatan: dari .gitignore ke .dockerignore

.gitignore mencegah file masuk riwayat Git; .dockerignore mencegah file masuk build context dan image. Sintaks pola-nya mirip, tapi keduanya independen, kamu tetap perlu .dockerignore sendiri meski sudah punya .gitignore.

.dockerignore
.git node_modules *.env .env dist/ tmp/ skincare-backend # binary hasil build lokal Dockerfile .dockerignore

Sekarang resep paling sederhana untuk API Go kita, satu tahap (multi-stage menyusul di Section 06). Perhatikan urutan COPY-nya.

Dockerfile
FROM golang:1.26 WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /skincare-api ./cmd/server EXPOSE 8080 CMD ["/skincare-api"]

Kenapa go.mod dan go.sum disalin lebih dulu, terpisah dari COPY . .? Karena cache. Docker meng-cache setiap layer berdasarkan input instruksinya. Layer RUN go mod download hanya akan di-build ulang bila go.mod/go.sum berubah. Selama dependency tetap, mengubah satu file handler di internal/product/ tidak membatalkan cache download dependency, build berikutnya lompat langsung ke go build. Kalau kamu menulis COPY . . sebelum go mod download, perubahan kode apa pun akan membuang cache dependency dan mengunduh ulang semua modul tiap build.

💡Letakkan yang jarang berubah di atas

Susun instruksi dari yang paling stabil (base image, dependency) ke yang paling sering berubah (kode aplikasi). Semakin tinggi sebuah layer di-cache awet, semakin banyak build di bawahnya yang ikut cepat.

📝EXPOSE itu dokumentasi

EXPOSE 8080 tidak benar-benar membuka port ke host; ia hanya menandai port yang dipakai container. Yang memetakan port ke host adalah flag -p saat docker run (dibahas di section networking).

06

Multi-stage Build untuk Go

Image kecil: pisahkan tahap compile dan runtime

Multi-stage build memisahkan tahap meng-compile dari tahap menjalankan, sehingga toolchain Go yang berat tidak pernah ikut ke image produksi.

Image single-stage di Section 05 berfungsi, tapi gemuk. golang:1.26 membawa compiler Go, git, header C, dan ratusan MB tooling yang hanya berguna saat build, sama sekali tidak dibutuhkan untuk menjalankan binary yang sudah jadi. Membawa semua itu ke produksi berarti image besar (lambat di-pull, lambat deploy) dan permukaan serangan yang luas (makin banyak paket, makin banyak CVE potensial).

Ide multi-stage: pakai satu stage golang untuk compile, lalu mulai stage runtime baru dari image super-minimal dan COPY --from hanya binary-nya. Semua isi stage build (compiler, source, cache) ditinggal dan tidak ikut ke image akhir.

🌉Jembatan: seperti build frontend lalu serve dist

Di React kamu menjalankan build (Vite, webpack) lalu hanya men-deploy folder dist; Node, dependency dev, dan source map dibuang. Multi-stage Go persis ide yang sama: build dengan toolchain penuh, lalu kirim hanya binary hasilnya ke runtime.

flowchart LR
  subgraph S1[Stage build: golang:1.26]
    A[go mod download] --> B[go build CGO_ENABLED=0]
    B --> C[/skincare-api binary/]
  end
  C -->|COPY --from=build| D
  subgraph S2[Stage runtime: distroless static]
    D[/api/] --> E[Image kecil, non-root]
  end

Dua tahap, satu artifact. Hanya binary yang menyeberang dari stage build ke stage runtime.

Berikut Dockerfile multi-stage untuk skincare-api kita. Modul Go memakai github.com/kamu/skincare-backend.

Dockerfile
# --- build --- FROM golang:1.26 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/api ./cmd/server # --- runtime --- FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /app/api /api USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/api"]

Dua detail krusial di stage build. CGO_ENABLED=0 mematikan linking ke pustaka C, sehingga Go menghasilkan binary statis yang tidak bergantung pada glibc apa pun, syarat agar bisa berjalan di image kosong seperti distroless static atau scratch. Flag -ldflags="-s -w" membuang tabel simbol dan info debug, memangkas ukuran binary tanpa mengubah perilakunya.

Untuk runtime kita pakai gcr.io/distroless/static-debian12 dengan tag :nonroot. Distroless adalah image yang hanya berisi runtime minimal: tidak ada shell, tidak ada package manager, tidak ada bin/sh. Hasilnya image akhir berukuran beberapa MB saja (binary kamu plus CA cert), dibanding ratusan MB versi single-stage. USER nonroot:nonroot membuat proses berjalan sebagai user tak-berhak, sehingga andai ada celah di aplikasi, penyerang tidak otomatis jadi root di dalam container.

💡Kecilkan attack surface, dan pin versinya

Distroless dan scratch menghilangkan shell serta utilitas yang biasa dipakai penyerang setelah berhasil masuk. Selalu pin tag base image (mis. golang:1.26, bukan golang:latest) agar build kamu reprodusibel dan tidak diam-diam berubah saat upstream merilis versi baru.

📝Distroless tidak punya shell

Karena tidak ada bin/sh, kamu tidak bisa docker exec lalu masuk ke shell untuk debugging, dan HEALTHCHECK harus exec-form yang memanggil binary-mu langsung, bukan perintah shell. Bila kamu butuh shell ringan, alpine adalah kompromi (sekitar 5 MB).

07

CMD vs ENTRYPOINT

Menentukan proses default container dengan benar

CMD dan ENTRYPOINT sama-sama menetapkan proses default, tapi keduanya menjawab pertanyaan yang berbeda: “apa yang dijalankan” versus “ini memang program apa”.

Sebuah image perlu tahu satu hal saat docker run dipanggil tanpa argumen: perintah apa yang menjadi proses pertama di dalam container. Di Go, jawabannya hampir selalu binary tunggal hasil build, misalnya server API skincare. Dua instruksi mengatur ini, dan memilih yang tepat menentukan apakah container kamu mau berhenti dengan rapi saat di-deploy ulang.

CMD
  • Memberi perintah default yang mudah ditimpa dari docker run image arg.
  • Cocok untuk image serba-guna: default jalankan server, tapi pengguna bebas mengganti dengan sh atau perintah lain.
  • Argumen di docker run mengganti seluruh CMD.
ENTRYPOINT
  • Menetapkan program yang selalu jalan, container “adalah” binary itu.
  • Cocok saat image punya satu tujuan jelas: ini server, titik.
  • Argumen di docker run menjadi argumen tambahan untuk ENTRYPOINT, bukan menggantinya.

Pola paling kokoh untuk service Go: pakai ENTRYPOINT untuk binary dan CMD untuk argumen default. ENTRYPOINT mengunci “ini server”, sementara CMD memberi flag default yang masih bisa diganti tanpa menyentuh binary-nya.

Dockerfile
ENTRYPOINT ["/api"] CMD ["--http-addr=:8080"]

Dengan kombinasi di atas, docker run img menjalankan /api --http-addr=:8080. Sementara docker run img --http-addr=:9090 menjalankan /api --http-addr=:9090: binary tetap, hanya argumennya yang ditimpa. Inilah yang membuat satu image bisa dipakai untuk port berbeda tanpa rebuild.

Yang krusial adalah perbedaan exec form dan shell form. Exec form (array JSON, ["/api"]) menjalankan binary secara langsung sebagai PID 1. Shell form (string, "/api") diam-diam dibungkus menjadi /bin/sh -c "/api", sehingga PID 1 adalah sh, dan binary Go kamu hanya anak proses.

flowchart TB
  subgraph exec["Exec form: ENTRYPOINT [&quot;/api&quot;]"]
    K1[Kernel] -->|SIGTERM| P1["PID 1 = /api"]
    P1 -->|graceful shutdown| OK[Tutup koneksi, drain]
  end
  subgraph shell["Shell form: ENTRYPOINT &quot;/api&quot;"]
    K2[Kernel] -->|SIGTERM| S1["PID 1 = /bin/sh"]
    S1 -.->|sinyal tidak diteruskan| P2["/api anak proses"]
    P2 -.->|tidak tahu mau stop| KILL["dipaksa SIGKILL setelah timeout"]
  end

Jalur sinyal SIGTERM. Hanya exec form yang menjadikan binary Go sebagai PID 1, sehingga ia menerima sinyal stop dan bisa shutdown bersih.

Kenapa ini penting di produksi? Saat orchestrator (Compose, Kubernetes, ECS) ingin menghentikan container, ia mengirim SIGTERM ke PID 1, menunggu beberapa detik, lalu SIGKILL paksa bila belum mati. Server Go yang baik menangkap SIGTERM untuk menghentikan terima request baru, menyelesaikan request yang sedang jalan, lalu menutup koneksi database. Itu semua hanya terjadi bila SIGTERM benar-benar sampai ke binary kamu.

cmd/server/main.go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %v", err) } }() <-ctx.Done() // SIGTERM tiba di sini (hanya jika binary PID 1) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx)
⚠️Shell form membunuh graceful shutdown

Tulis ENTRYPOINT "/api" (string) dan PID 1 menjadi /bin/sh, bukan binary kamu; sh tidak meneruskan SIGTERM, jadi signal.NotifyContext di Go tidak pernah terpicu dan container dibunuh paksa setelah timeout, memutus request yang sedang berjalan.

💡Selalu array JSON

Pakai bentuk exec untuk ENTRYPOINT/CMD: ["/api"], bukan /api. Selain meneruskan sinyal dengan benar, ia juga menghindari binary distroless yang tidak punya /bin/sh sama sekali (shell form akan langsung gagal di sana).

🌉Jembatan: dari npm scripts ke entrypoint

Di proyek Node, npm start memetakan ke satu perintah default di package.json; ENTRYPOINT + CMD adalah versi container dari ide itu, tapi dengan konsekuensi sinyal yang nyata, karena proses ini benar-benar menjadi PID 1 di namespace-nya sendiri.

08

Environment Variables dan Config

Config lewat env, secret tidak di-bake ke image

Satu image yang sama harus bisa berjalan di laptop, staging, dan produksi; yang membedakan hanyalah environment variable yang disuntikkan saat runtime.

Prinsip ini berasal dari twelve-factor app: simpan config di environment, bukan di kode atau di image. Image adalah artefak yang immutable dan bisa dibagikan; begitu kamu memanggang nilai spesifik produksi ke dalamnya, image itu tidak lagi portabel dan, lebih buruk, bisa membocorkan rahasia. Go membuat pola ini nyaman lewat os.Getenv, tanpa library tambahan apa pun.

internal/config/config.go
package config import ( "fmt" "os" ) type Config struct { AppEnv string Port string DatabaseURL string RedisAddr string } func Load() (Config, error) { c := Config{ AppEnv: getEnv("APP_ENV", "development"), Port: getEnv("PORT", "8080"), RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), } c.DatabaseURL = os.Getenv("DATABASE_URL") // wajib, tanpa default if c.DatabaseURL == "" { return c, fmt.Errorf("DATABASE_URL wajib diisi") } return c, nil } func getEnv(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return v } return fallback }

Perhatikan dua sikap berbeda: nilai non-sensitif seperti PORT punya default aman, sedangkan DATABASE_URL yang berisi kredensial sengaja tanpa default dan gagal cepat bila kosong. Lebih baik container menolak start daripada diam-diam menyambung ke database yang salah.

Di Dockerfile, ENV hanya pantas untuk default non-rahasia, misalnya ENV PORT=8080. Saat run, suntikkan nilai nyata lewat -e per variabel atau --env-file untuk berkas:

Terminal
docker run --rm -p 8080:8080 \ --env-file .env \ -e APP_ENV=production \ ghcr.io/kamu/skincare-backend:1.4.0
flowchart LR
  IMG["Image yang sama
  skincare-backend:1.4.0"]
  IMG --> DEV["dev
  APP_ENV=development
  DATABASE_URL=...localhost"]
  IMG --> STG["staging
  APP_ENV=staging
  DATABASE_URL=...rds-stg"]
  IMG --> PRD["prod
  APP_ENV=production
  DATABASE_URL=...rds-prod"]

Satu artefak, banyak environment. Image dibangun sekali; perbedaan tiap lingkungan murni datang dari env yang disuntikkan saat runtime.

Bagian paling berbahaya adalah secret. Ada tiga cara salah yang sering tidak disadari. Pertama, COPY .env /app/: berkas kredensial ikut tersimpan permanen di layer image. Kedua, mengirim secret lewat --build-arg: nilai ARG tercatat di metadata image dan terlihat lewat docker history. Ketiga, menaruh secret di ENV Dockerfile: ia tertulis di setiap layer dan terbaca siapa pun yang menarik image.

⚠️Secret tidak pernah masuk image

Jangan COPY .env ke image, jangan kirim kredensial via ARG/--build-arg (terlihat di docker history), dan jangan tulis secret di ENV Dockerfile. Secret hanya disuntikkan saat runtime lewat --env-file, secret manager, atau orchestrator.

💡Kunci .dockerignore

Pastikan .env ada di .dockerignore agar tidak pernah ikut ke build context, bahkan saat kamu menulis COPY . .. Tanpa baris ini, satu COPY ceroboh cukup untuk membocorkan seluruh kredensial lokalmu ke dalam image.

🌉Jembatan: dari .env Laravel/Vite

.env di Laravel dibaca lewat env()/config() dan di Vite lewat import.meta.env; ide bahwa config datang dari environment identik. Bedanya, di container .env bukan dibaca dari berkas yang ikut image, melainkan disuntikkan dari luar ke proses Go yang membacanya via os.Getenv.

09

Docker Networking Dasar

Bridge network, DNS service, dan jebakan localhost

Setiap container hidup di network namespace-nya sendiri, jadi localhost di dalam container API menunjuk container itu sendiri, bukan laptop dan bukan container database.

Inilah sumber kebingungan paling umum saat pertama menjalankan beberapa container. Di laptop, frontend dan backend sama-sama di localhost, jadi memanggil localhost:5432 untuk Postgres terasa alami. Di Docker, tiap container punya stack jaringan terisolasi: localhost selalu berarti “diri sendiri”. Container API yang mencoba menyambung ke localhost:5432 sedang mencari Postgres di dalam dirinya sendiri, yang tidak ada di sana.

Solusinya adalah user-defined bridge network. Network bawaan bernama bridge tidak menyediakan DNS antar container, jadi container harus saling kenal lewat IP yang berubah-ubah. Begitu kamu membuat network sendiri, Docker mengaktifkan DNS internal: tiap container bisa dipanggil lewat nama service atau nama container-nya.

flowchart LR
  subgraph host["Host (laptop)"]
    BROWSER["curl localhost:8080"]
  end
  subgraph net["network: app (user-defined bridge)"]
    API["container: api
    listen :8080"]
    DB[("container: db
    postgres :5432")]
  end
  BROWSER -->|"-p 8080:8080"| API
  API -->|"connect db:5432
  via DNS internal"| DB

Dua container dalam satu network. Host menjangkau api lewat port yang di-publish; di dalam network, api menjangkau db lewat nama service, bukan localhost.

Coba sendiri. Buat network, jalankan Postgres dan API di network yang sama, lalu API menyambung ke host bernama db:

Terminal
docker network create app docker run -d --name db --network app \ -e POSTGRES_PASSWORD=secret postgres:17 docker run -d --name api --network app \ -p 8080:8080 \ -e DATABASE_URL="postgres://postgres:secret@db:5432/postgres?sslmode=disable" \ ghcr.io/kamu/skincare-backend:1.4.0

Di Go, tidak ada yang istimewa: kode tetap memanggil DATABASE_URL. Yang berubah hanya host di dalam URL, dari localhost saat dev lokal menjadi db (nama container) saat di dalam network Docker. DNS internal Docker menerjemahkan db ke IP container Postgres saat itu juga.

Flag -p 8080:8080 adalah hal terpisah: ia mem-publish port container ke host (host:container), supaya browser di laptop bisa menjangkau API. Tanpa -p, API tetap saling bicara dengan db di dalam network, tapi tidak terjangkau dari luar. Database biasanya justru tidak di-publish: cukup terjangkau API lewat DNS internal, dan tidak terekspos ke jaringan host demi keamanan.

⚠️localhost di container bukan laptopmu

Di dalam container API, localhost menunjuk container itu sendiri. Untuk menjangkau service lain pakai nama service/container (db:5432); untuk menjangkau proses yang berjalan di host laptop, gunakan host.docker.internal, bukan localhost.

💡Default bridge tidak punya DNS

Hanya user-defined bridge (docker network create app) yang memberi resolusi nama antar container; network bridge bawaan tidak. Di Compose, kamu mendapat user-defined network otomatis, sehingga service saling memanggil lewat namanya tanpa konfigurasi tambahan.

🌉Jembatan: dari localhost:3000 ke db:5432

Saat dev frontend memanggil localhost:3000 karena semua proses berbagi satu mesin, di Docker tiap container adalah “mesin” sendiri. Memanggil database menjadi db:5432 (nama service di network), persis seperti memanggil host yang berbeda di jaringan nyata.

10

Volumes dan Bind Mounts

Data yang harus bertahan di luar lifecycle container

Writable layer sebuah container itu fana, jadi data yang harus hidup lebih lama dari container wajib ditaruh di luar lapisan itu.

Di section 04 kita lihat container menambahkan satu writable layer tipis di atas image yang read-only. Semua tulisan baru, file PostgreSQL, log, upload, mendarat di lapisan itu. Begitu container dihapus dengan docker rm, lapisan itu ikut hilang permanen. Untuk stateless API hal ini justru sehat: container boleh dibuang dan dibuat ulang tanpa beban. Tapi database tidak boleh kehilangan datanya hanya karena kita docker compose down lalu up lagi.

Docker menawarkan dua mekanisme untuk menyimpan data di luar writable layer: named volume dan bind mount. Keduanya memetakan sebuah path di dalam container ke penyimpanan persisten di host, tapi siapa yang mengelolanya dan untuk apa pemakaiannya berbeda.

Named volume vs bind mount Named volume Container (postgres) /var/lib/postgresql/data Volume: pgdata dikelola Docker · tetap bertahan -v pgdata:/var/lib/postgresql/data Bind mount Container (api, dev) /app Host folder ./src milik laptop · edit langsung -v $(pwd)/src:/app
Named volume vs bind mount. Volume dikelola Docker dan bertahan; bind mount memetakan folder host ke container.

Named volume: dikelola Docker

Named volume adalah penyimpanan yang Docker buat dan kelola sendiri di area internalnya (di Linux biasanya /var/lib/docker/volumes). Kamu cukup menyebut namanya, tidak perlu tahu path fisiknya. Volume ini bertahan walau container dihapus, dan bisa dipasang ulang ke container baru. Inilah pilihan tepat untuk data database.

Terminal
docker volume create pgdata docker run -d --name db \ -e POSTGRES_PASSWORD=rahasia \ -v pgdata:/var/lib/postgresql/data \ postgres:17

Sintaks -v pgdata:/var/lib/postgresql/data artinya: pasang named volume bernama pgdata ke path data internal PostgreSQL. Hapus container db, jalankan ulang dengan flag -v yang sama, dan semua tabel produk skincare tetap utuh.

🧳Analogi: loker bagasi

Named volume seperti loker bagasi berlabel di stasiun, kamu titip barang dengan nomor loker tanpa peduli rak fisiknya, dan bisa ambil lagi kapan pun walau gerbongmu (container) sudah ganti.

Bind mount: folder host langsung

Bind mount memetakan folder konkret di mesin host ke path di dalam container. Kamu yang menunjuk path host-nya secara eksplisit. Karena perubahan di host langsung terlihat di container (dan sebaliknya), bind mount ideal untuk development: edit source di editor, container melihat file baru tanpa rebuild image.

Terminal
docker run -d --name api-dev \ -v "$(pwd)":/src \ -p 8080:8080 \ golang:1.26 \ sh -c "cd /src && go run ./cmd/server"

Di sini folder proyek lokal ($(pwd)) terhubung ke /src di container, cocok dipadukan dengan tool hot reload seperti Air untuk siklus edit-jalan yang cepat di proyek github.com/kamu/skincare-backend.

🌉Jembatan: dari folder proyek lokal ke container

Bayangkan folder proyekmu di laptop dan folder di dalam container sebagai dua jendela yang menatap rak file yang sama; ubah satu sisi, sisi lain langsung ikut, persis seperti volume mount di docker-compose Laravel Sail untuk source aplikasi.

Kapan memakai yang mana

AspekNamed volumeBind mount
PengelolaDockerKamu (path host eksplisit)
Pemakaian utamaData database, state produksiSource code dev, file konfigurasi
PortabilitasTinggi, lepas dari path hostTerikat struktur folder host
KecocokanPersistensi jangka panjangIterasi cepat & hot reload
⚠️Jebakan: bind mount menutupi folder bawaan image

Bila kamu bind-mount folder proyek ke /app padahal image sudah berisi dependensi terinstal di sana (mis. node_modules hasil install), mount itu menimpa dan menyembunyikan folder bawaan image sehingga dependensi seolah hilang; solusinya pisahkan path data dari path yang di-mount.

Inti volume & bind mount

  • Writable layer hilang saat container dihapus, data penting harus keluar dari sana.
  • Named volume dikelola Docker dan bertahan, pakai untuk data PostgreSQL.
  • Bind mount memetakan folder host, pakai untuk source code saat development.
  • Hati-hati bind mount menutupi folder bawaan image seperti node_modules.
11

Docker Compose: Multi-Container Stack

Satu compose.yaml plus healthcheck dan startup order

Compose menggantikan deretan perintah docker run panjang dengan satu file YAML yang mendeskripsikan seluruh stack lokalmu.

Aplikasi backend nyata jarang berdiri sendiri. Proyek github.com/kamu/skincare-backend butuh API Go, PostgreSQL untuk data, Redis untuk cache, dan satu langkah migrasi skema. Menjalankan semuanya manual dengan docker run dan --network yang benar itu repetitif dan rawan salah. Docker Compose mendefinisikan services, masing-masing dengan build/image, ports, environment, depends_on, volumes, dan networks, semuanya dalam satu compose.yaml.

🌉Jembatan: dari Sail atau multi-terminal ke satu file stack

Jika di JS kamu terbiasa membuka tiga terminal (npm run dev, server db, worker) atau di Laravel memakai Sail, Compose adalah satu file deklaratif yang menggantikan ritual itu; docker compose up menyalakan seluruh stack sekaligus.

flowchart LR
  M[migrate one-off] -->|run sekali| DB[(postgres)]
  API[api Go] --> DB
  API --> R[(redis)]
  API -.depends_on service_healthy.-> DB

Topologi stack lokal. API bicara ke PostgreSQL dan Redis; migrasi jalan sekali sebelum API melayani trafik.

compose.yaml lengkap

compose.yaml
services: postgres: image: postgres:17 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: rahasia POSTGRES_DB: skincare volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 redis: image: redis:7 migrate: image: migrate/migrate depends_on: postgres: condition: service_healthy volumes: - ./migrations:/migrations command: ["-path", "/migrations", "-database", "postgres://postgres:rahasia@postgres:5432/skincare?sslmode=disable", "up"] api: build: . ports: - "8080:8080" environment: DATABASE_URL: postgres://postgres:rahasia@postgres:5432/skincare?sslmode=disable REDIS_ADDR: redis:6379 depends_on: postgres: condition: service_healthy volumes: pgdata:

Perhatikan tidak ada field version: di atas; field itu kini obsolete dan diabaikan, gaya Compose modern cukup mulai langsung dari services:. Layanan saling memanggil lewat nama (postgres, redis) karena Compose membuat jaringan default dengan DNS internal antar-service.

depends_on tidak menjamin “siap menerima koneksi”

Ini sumber bug yang paling sering menggigit. depends_on polos hanya menjamin container PostgreSQL sudah started, bukan bahwa proses di dalamnya sudah ready menerima koneksi. PostgreSQL butuh beberapa detik untuk inisialisasi sebelum membuka socket. Tanpa pengaman, API-mu menembak database yang belum siap dan langsung mati.

⚠️Jebakan: started bukan berarti ready

depends_on saja hanya menunggu container hidup; tambahkan healthcheck pada postgres plus condition: service_healthy agar Compose menunggu sampai pg_isready lulus, dan tetap pasang retry koneksi di aplikasi sebagai jaring pengaman.

internal/db/connect.go
func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { var pool *pgxpool.Pool var err error for attempt := 1; attempt <= 5; attempt++ { pool, err = pgxpool.New(ctx, url) if err == nil && pool.Ping(ctx) == nil { return pool, nil } time.Sleep(time.Duration(attempt) * time.Second) } return nil, fmt.Errorf("gagal konek db setelah retry: %w", err) }

Menjalankan stack

Nyalakan di background

docker compose up -d membangun image API, menarik image lain, lalu menjalankan semua service sesuai urutan dependensi.

Pantau log API

docker compose logs -f api mengikuti output API secara live untuk memastikan ia terhubung ke database dan Redis.

Bersihkan total

docker compose down -v menghentikan dan menghapus container beserta named volume pgdata, mengembalikan stack ke kondisi bersih.

📝-v menghapus data

Tambahkan -v pada down hanya saat kamu memang ingin membuang data; tanpa -v, volume pgdata tetap aman dan data PostgreSQL bertahan antar-restart.

12

Logs, Exec, dan Resource

stdout/stderr, debugging interaktif, dan batas resource

Container yang baik tidak menulis log ke file, ia mencetak ke stdout dan membiarkan platform yang mengumpulkannya.

Di dunia container, konvensinya jelas: aplikasi menulis log ke stdout dan stderr, bukan ke file di dalam container. Alasannya praktis. Writable layer container itu fana (lihat section 10), jadi log yang ditulis ke file ikut hilang saat container dibuang. Dengan mencetak ke stdout/stderr, log otomatis ditangkap oleh Docker, CI, dan platform cloud, lalu bisa dibaca dengan satu perintah seragam.

🌉Jembatan: dari log file ke stdout container-native

Jika kamu terbiasa console.log yang mendarat di file atau storage/logs/laravel.log ala Laravel, dalam container lupakan file itu; cukup tulis ke stdout (di Go cukup log.Print atau logger terstruktur ke os.Stdout) dan platform yang mengurus pengumpulan serta rotasi.

Membaca log dan exit code

Terminal
docker compose logs -f api # ikuti log API live docker logs --tail 50 db # 50 baris terakhir container db docker inspect -f '{{.State.ExitCode}}' api # exit code terakhir

Exit code memberi sinyal cepat: 0 artinya keluar normal, 1 error aplikasi, 137 umumnya berarti container di-kill karena melewati batas memori (OOM). docker inspect membuka seluruh metadata container, dari status, mount, sampai konfigurasi jaringan.

Skenario: API Go gagal konek database

Misalkan docker compose logs api menampilkan gagal konek db setelah retry. Langkah debug interaktif: masuk ke container dan periksa lingkungannya dari dalam.

Terminal
docker exec -it api sh # masuk shell container api env | grep DATABASE_URL # cek env yang benar-benar terbaca docker exec -it db psql -U postgres -d skincare -c '\dt' # uji koneksi dari sisi db

Pola ini menyingkap penyebab umum: DATABASE_URL menunjuk localhost (salah, harusnya nama service postgres), password tidak cocok, atau database memang belum siap. docker exec menjalankan perintah baru di container yang sudah hidup, sementara flag -it memberi terminal interaktif.

📝exec untuk inspeksi, bukan maintenance

docker exec cocok untuk mengintip kondisi sesaat, tapi bukan cara merawat container produksi jangka panjang; perubahan yang kamu lakukan dari dalam hilang saat container diganti, jadi perbaikan sejati selalu lewat image atau konfigurasi.

Batas resource dan OOM

Container bukan VM, ia berbagi CPU dan memori host yang sama. Tanpa batas, satu container bocor memori bisa menjerumuskan seluruh mesin. Docker memberi rem lewat --memory dan --cpus. Saat pemakaian memori melewati batas, kernel melakukan OOM kill dan container mati dengan exit code 137.

Terminal
docker run --rm --memory=128m --cpus=1.5 \ ghcr.io/kamu/skincare-backend:1.0.0

Di Compose, batas serupa diatur lewat deploy.resources.limits (atau mem_limit untuk gaya ringkas). Menetapkan batas sejak development membantu menemukan kebocoran lebih awal, sebelum tagihan produksi yang mengingatkanmu.

compose.yaml
services: api: build: . deploy: resources: limits: memory: 256m cpus: "1.5"

Inti logs, exec, dan resource

  • Tulis log ke stdout/stderr, biarkan Docker dan platform yang mengumpulkan serta merotasi.
  • docker logs dan docker inspect membaca output dan exit code; 137 menandai OOM kill.
  • docker exec -it untuk inspeksi sesaat, bukan jalur maintenance produksi.
  • Batasi memori dan CPU dengan --memory/--cpus atau deploy.resources untuk melindungi host.
13

Image Tagging, Versioning, dan Registry

Tag yang jelas supaya deploy terlacak dan rollback mudah

Image yang sudah dibangun tidak ada gunanya kalau tidak bisa kamu temukan lagi dengan pasti versi mana yang sedang berjalan di production.

Tag adalah alamat manusiawi untuk sebuah image. Tanpa disiplin penamaan, kamu akan terjebak pertanyaan klasik saat insiden: “yang lagi jalan di production itu build yang mana?” Jawaban yang baik bukan “yang terbaru”, tapi sebuah identitas yang bisa ditelusuri sampai ke commit Git tertentu. Registry adalah tempat image itu disimpan dan dibagikan, persis seperti registry npm menyimpan paket, hanya saja yang kita simpan adalah artefak runtime yang sudah jadi, bukan sumber.

🌉Jembatan: dari versi paket npm ke versi artefak image

Di npm kamu kunci versi lewat package-lock.json agar instalasi deterministik; di Docker, tag semver plus digest adalah penguncinya, supaya “yang dideploy” selalu artefak yang sama persis.

Jebakan tag latest

latest hanyalah label biasa yang menunjuk ke image terakhir yang kamu tag dengan nama itu. Ia tidak berarti “versi paling baru” secara semantik dan tidak deterministik: dua server yang menjalankan docker pull skincare-api:latest pada waktu berbeda bisa mendapat biner yang berbeda. Saat terjadi bug, kamu kehilangan kemampuan rollback karena tidak tahu versi sebelumnya bernama apa.

⚠️Jangan andalkan latest untuk deploy serius

Pin tag semver atau git SHA, dan untuk jaminan penuh referensikan digest image@sha256:…; latest mutable dan membuat deploy tidak reproducible.

Tiga lapis penamaan

JenisContohSifat
Semantic tagskincare-api:1.4.0Dibaca manusia, mengikuti rilis
Git SHA tagskincare-api:9f3a1c2Telusur balik ke commit persis
Immutable digestskincare-api@sha256:ab12…Tidak bisa berubah, jaminan byte-identik

Praktik yang sehat: satu image fisik diberi beberapa tag sekaligus saat build, sehingga satu artefak bisa dirujuk lewat versi semver yang ramah manusia maupun SHA yang presisi.

Terminal
# satu build, beberapa tag menunjuk image fisik yang sama docker build \ -t skincare-api:dev \ -t skincare-api:$(git rev-parse --short HEAD) \ . # beri ulang tag image yang sudah ada untuk tujuan registry docker tag skincare-api:$(git rev-parse --short HEAD) \ ghcr.io/owner/skincare-api:$(git rev-parse --short HEAD)

Registry: tempat image tinggal

Ada beberapa registry umum, semuanya bicara protokol yang sama sehingga alur login, build, tag, push, pull identik; yang berbeda hanya nama host dan cara autentikasinya.

RegistryHostCocok untuk
Docker Hubdocker.ioImage publik, base image resmi
GitHub Container Registryghcr.ioImage privat menyatu dengan repo & CI
AWS ECR<acct>.dkr.ecr.<region>.amazonaws.comDeploy di ekosistem AWS
Terminal
# login ke ghcr.io (token via stdin, jangan tempel di argumen) echo "$GHCR_TOKEN" | docker login ghcr.io -u owner --password-stdin # push tag SHA yang sudah diberi prefix host registry docker push ghcr.io/owner/skincare-api:$(git rev-parse --short HEAD) # di sisi lain (CI/staging/prod) tarik versi yang sama persis docker pull ghcr.io/owner/skincare-api:9f3a1c2
💡Login ECR berumur pendek

ECR memberi password sementara, jadi alur login-nya: aws ecr get-login-password —region eu-west-1 | docker login —username AWS —password-stdin <acct>.dkr.ecr.eu-west-1.amazonaws.com, lalu push seperti biasa.

Build once, run anywhere

Inti dari registry adalah memisahkan kapan image dibangun dari kapan ia dijalankan. Image dibangun sekali di CI, lalu artefak yang sama persis ditarik oleh staging dan production. Tidak ada lagi “build ulang di server” yang berisiko menghasilkan biner berbeda karena perbedaan lingkungan.

flowchart LR
  CI["CI: docker build + push"] --> REG["Registry<br/>ghcr.io/owner/skincare-api:9f3a1c2"]
  REG --> STG["Staging<br/>docker pull :9f3a1c2"]
  REG --> PRD["Production<br/>docker pull :9f3a1c2"]

Satu artefak, banyak target. Staging dan production menarik tag SHA yang sama, sehingga apa yang diuji adalah apa yang dirilis.

Yang perlu menempel

  • Tag adalah identitas yang bisa ditelusuri, bukan sekadar label “terbaru”.
  • latest mutable dan merusak rollback; pin semver, SHA, atau digest.
  • Beri satu image beberapa tag dalam satu build agar fleksibel dirujuk.
  • Build sekali di CI, tarik artefak identik di setiap lingkungan.
14

Security Dockerfile dan Image Production

Non-root, base minimal, dev vs prod, migration terkontrol

Image production yang baik membawa sesedikit mungkin: satu biner, tanpa shell, tanpa secret, dan berjalan sebagai user biasa.

Setiap hal yang ada di dalam image adalah permukaan serang. Shell, package manager, compiler, dan tool debug semuanya berguna saat mengembangkan, tapi di production mereka hanya menambah cara bagi penyerang untuk bergerak setelah masuk. Prinsipnya sederhana: image production harus kecil, berjalan non-root, dan tidak menyimpan kredensial di dalam layer.

🌉Jembatan: dari audit dependensi ke pemindaian image

Sama seperti npm audit memeriksa kerentanan di pohon dependensi JS, docker scout cves atau trivy image memindai kerentanan di image kamu, termasuk paket OS pada base image, bukan cuma kode aplikasi.

Dockerfile yang diperketat

Tiga pengetatan penting: pin versi base image (jangan biarkan mengambang), pakai base runtime minimal, dan jalankan sebagai user non-root. Distroless cocok untuk biner Go statis karena hampir kosong, tidak punya shell sama sekali.

Dockerfile
# --- build --- FROM golang:1.26 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/server # --- runtime --- FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /app /app USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/app"]
⚠️Jangan jalankan proses sebagai root

Default container berjalan sebagai root; bila penyerang menembus aplikasi, root di dalam container memperbesar dampaknya. Selalu set USER non-root, lewat tag :nonroot distroless atau adduser pada alpine.

Dua image untuk dua dunia

Kesalahan umum adalah mengirim image development ke production. Image dev sengaja gemuk: ada hot reload, bind mount ke kode sumber, dan tool debug. Image production justru kebalikannya, sengaja kurus dan tertutup.

Image development
  • Hot reload & rebuild cepat
  • Bind mount ke kode sumber host
  • Shell, debugger, tool jaringan ikut
  • Berjalan root demi kenyamanan
Image production
  • Satu biner statis, tanpa shell
  • Base distroless yang dipin
  • Permukaan serang minimal
  • Berjalan sebagai user non-root
⚠️Jangan kirim image dev ke production

Image dev membawa tool dan bind mount yang tidak ada di server, sehingga rawan dan tidak deterministik; bangun image production terpisah lewat multistage.

Memindai sebelum rilis

Jadikan pemindaian bagian dari pipeline, bukan ritual sesekali. Pindai image hasil build, dan jangan sertakan secret di dalam layer (suntikkan via environment atau secret manager saat runtime).

Terminal
docker scout cves ghcr.io/owner/skincare-api:9f3a1c2 docker scout quickview ghcr.io/owner/skincare-api:9f3a1c2 trivy image ghcr.io/owner/skincare-api:9f3a1c2

Migration terkontrol, bukan otomatis tiap startup

Godaan besar adalah menjalankan migrasi skema saat aplikasi boot. Itu berbahaya: bila kamu menjalankan tiga replika API, ketiganya akan berlomba menjalankan migrasi yang sama, dan satu migrasi gagal bisa menahan seluruh layanan naik. Migrasi sebaiknya menjadi langkah terpisah dan terkontrol, dijalankan satu kali sebelum API yang baru menerima trafik.

🌉Jembatan: dari php artisan migrate ke tool migrasi Go

Di Laravel migrasi adalah perintah eksplisit (php artisan migrate) yang kamu jalankan sadar, bukan saat tiap request; di Go pakai pola sama dengan tool seperti golang-migrate sebagai job one-off, terpisah dari proses API.

compose.yaml
services: db: image: postgres:17 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 migrate: image: migrate/migrate depends_on: db: condition: service_healthy volumes: - ./migrations:/m command: ["-path", "/m", "-database", "${DATABASE_URL}", "up"] api: build: . depends_on: migrate: condition: service_completed_successfully

Migrasi sebagai service one-off. Service migrate selesai dulu (exit 0), baru API naik, sehingga skema selalu siap sebelum trafik masuk.

Yang perlu menempel

  • Image production kecil, non-root, tanpa shell, tanpa secret di layer.
  • Pin versi base image dan pindai dengan scout atau trivy di pipeline.
  • Pisahkan image dev (hot reload, bind mount) dari image prod yang kurus.
  • Migrasi adalah langkah one-off terkontrol, bukan efek samping startup API.
15

Studi Kasus: Containerize Go API Skincare

Stack lengkap plus pitfalls khas developer JS/PHP

Saatnya menyatukan semua konsep menjadi satu stack lokal yang realistis: API Go, PostgreSQL, dan Redis, dijalankan dengan satu perintah.

Target kita adalah skincare-api, backend online shop skincare yang sudah kamu kenal sepanjang course ini. API ini membaca produk dari PostgreSQL, men-cache hasilnya di Redis, dan mengekspos GET /healthz agar Compose tahu kapan container siap melayani. Tujuannya bukan sekadar “bisa jalan”, tapi menyusun stack yang mirip produksi: build kecil dengan multi-stage, config lewat env, dan dependency yang digerbangi healthcheck.

Dockerfile final skincare-api

Kita pakai pola multi-stage yang sudah dibahas: stage golang:1.26 untuk compile, lalu runtime distroless yang ramping dan non-root. Biner Go statis (CGO_ENABLED=0) muat di distroless/static-debian12 tanpa shell, jadi permukaan serangan tipis.

Dockerfile
# --- build --- FROM golang:1.26 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/server # --- runtime --- FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /app /app USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/app"]

Perhatikan COPY go.mod go.sum lebih dulu sebelum COPY . .. Itu bukan gaya, melainkan trik cache layer: selama dependency tidak berubah, go mod download tidak dijalankan ulang walau kode handler kamu berubah ratusan kali.

Config env dan health endpoint

Aplikasi membaca semuanya dari environment, tidak ada nilai hardcode. Health endpoint dibuat ringan, tidak menyentuh database, agar cepat dan tidak ikut menumbangkan API saat DB lambat.

cmd/server/main.go
package main import ( "net/http" "os" "github.com/kamu/skincare-backend/internal/server" ) func main() { cfg := server.Config{ Addr: ":" + envOr("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"), RedisAddr: os.Getenv("REDIS_ADDR"), } http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) }) server.Run(cfg) }
🌉Jembatan: dari .env Laravel ke env container

Di Laravel, .env dibaca proses PHP saat boot; di sini env disuntik Compose ke proses container, jadi satu image yang sama bisa jalan beda config tanpa rebuild.

Arsitektur stack final

flowchart LR
  Dev["curl :8080"] --> API["skincare-api\n(EXPOSE 8080)"]
  API -->|appnet DNS: db:5432| DB[("postgres:17")]
  API -->|appnet DNS: cache:6379| R[("redis:7")]
  DB -.named volume.-> V[("pgdata")]

Stack lokal skincare. API bicara ke db dan cache lewat nama service, bukan IP; data Postgres bertahan di named volume pgdata.

Compose stack final

compose.yaml
services: api: build: . ports: - "8080:8080" environment: PORT: "8080" DATABASE_URL: "postgres://app:secret@db:5432/skincare?sslmode=disable" REDIS_ADDR: "cache:6379" depends_on: db: condition: service_healthy cache: condition: service_started db: image: postgres:17 environment: POSTGRES_USER: app POSTGRES_PASSWORD: secret POSTGRES_DB: skincare volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s timeout: 3s retries: 5 cache: image: redis:7 volumes: pgdata:

Tidak ada version: di atas. Atribut itu sudah usang dan diabaikan Compose modern, malah memunculkan peringatan. Service bernama db dan cache, persis nama yang dipakai API di DATABASE_URL dan REDIS_ADDR, karena Compose membuat user-defined network dengan DNS antar service otomatis.

Lima pitfalls khas dan cara debug

Inilah lima jebakan yang paling sering menjatuhkan developer yang baru pindah dari npm run dev atau php artisan serve. Sebagian besar bukan bug Docker, melainkan beda mental model antara “proses di laptop” dan “proses di dalam container”.

PitfallGejalaSebabSolusi
Localhost trapAPI gagal connect DB walau Postgres “jalan”Kode pakai localhost:5432; di dalam container, localhost = container itu sendiri, bukan hostPakai nama service: db:5432, andalkan DNS Compose
Stale imagePerubahan kode tidak muncul setelah upLupa rebuild; Compose pakai image lama yang sudah ter-cachedocker compose up --build atau docker compose build api
Wrong port mappingcurl :8080 connection refusedApp listen :3000 tapi mapping 8080:8080, atau urutan host:container terbalikSamakan ports dengan port app; ingat format host:container
Missing envApp panic atau koneksi kosong saat startDATABASE_URL tidak diset, os.Getenv mengembalikan string kosongDefinisikan di environment/env_file; cek docker compose config
Volume shadowingFile yang sudah di-build hilang di containerBind mount menimpa direktori berisi artefak image dengan folder host kosongJangan mount over path build; mount hanya source yang memang perlu
⚠️Localhost trap adalah jebakan nomor satu

Di dalam container, 127.0.0.1 menunjuk ke container itu sendiri. Postgres ada di container lain, jadi alamatnya db:5432, bukan localhost:5432.

💡Debug cepat: config dan logs

Jalankan docker compose config untuk melihat env final yang ter-resolve, dan docker compose logs -f api untuk membaca alasan crash sebelum menebak-nebak.

Hands-on: bangun, jalankan, cek health

Build image API

Jalankan docker compose build api agar Dockerfile multi-stage dieksekusi dan layer dependency masuk cache untuk build berikutnya.

Jalankan seluruh stack

docker compose up -d menyalakan db, cache, lalu api; depends_on: condition: service_healthy menahan API sampai pg_isready lulus.

Cek health endpoint

curl localhost:8080/healthz harus mengembalikan {"status":"ok"}; bila refused, periksa docker compose ps dan logs api.

🔗Satu perintah, satu lingkungan

Compose ke stack ini seperti docker-compose resep dapur: satu file mendeklarasikan bahan (image), takaran (env), dan urutan masak (depends_on), lalu up memasaknya identik di laptop siapa pun.

Dengan ini kamu punya backend skincare yang berjalan lokal layaknya produksi mini: build ramping, dependency tergerbang sehat, dan config yang bisa dipindah tanpa menyentuh image. Inilah fondasi yang sama yang nanti diangkat ke cloud.

16

Topik Lanjutan dan Peta ke Deploy

Dari image lokal ke CI/CD dan AWS

Image yang jalan di laptop adalah setengah cerita; setengah lainnya adalah membawanya ke registry dan menjalankannya di cloud secara otomatis.

Setelah skincare-api containerized, langkah dewasa berikutnya adalah menghapus tahap manual. Alih-alih docker build lalu docker push dari laptop, sebuah CI pipeline melakukannya setiap kali kamu merge ke main: build image, jalankan test, lalu push ke registry. Dari registry, platform orkestrasi menarik image dan menjalankannya. Course ini berhenti di gerbang itu, tapi penting kamu lihat petanya supaya tahu ke mana arah berikutnya.

Peta pipeline ke AWS

flowchart LR
  Push["git push main"] --> CI["CI: build + test"]
  CI --> Img["docker build\n-t ...:sha"]
  Img --> ECR["push ke AWS ECR\n(registry)"]
  ECR --> ECS["deploy ECS Fargate\n(jalankan container)"]
  ECS --> RDS[("RDS PostgreSQL")]

Dari commit ke produksi. CI membangun dan menguji, mendorong image ke ECR, lalu ECS Fargate menjalankan container yang sama, terhubung ke database RDS terkelola.

Potongan-potongan yang akan kamu temui

Beberapa istilah AWS akan muncul, dan masing-masing memetakan rapi ke konsep yang sudah kamu kuasai. ECR hanyalah registry privat (seperti GHCR, tapi milik AWS). ECS Fargate adalah cara menjalankan container tanpa mengurus server. RDS adalah Postgres terkelola, pengganti container db lokalmu. Untuk konteks resmi, lihat dokumentasi Amazon ECR dan panduan AWS Fargate.

🌉Jembatan: dari Vercel/Forge ke ECS

Kalau terbiasa deploy Next.js di Vercel atau Laravel via Forge, ECS Fargate adalah ide serupa untuk container: kamu serahkan artefak (image), platform yang menjalankan dan menskalakannya.

Akselerasi build: BuildKit dan cache mounts

BuildKit sudah jadi builder default, jadi kamu otomatis menikmati build paralel dan cache layer. Lebih jauh, cache mount membuat cache modul Go bertahan antar build tanpa masuk ke image final, memangkas waktu go mod download di CI.

Dockerfile (cache mount)
RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -o /app ./cmd/server
💡compose watch untuk loop dev

docker compose watch menyinkronkan perubahan source ke container dan me-rebuild saat perlu, mendekati pengalaman hot-reload npm run dev tanpa meninggalkan stack Compose.

Arah lanjutan

CI/CD pipeline

Otomatiskan build, test, dan push image bertag digest tiap merge, agar deploy reproducible dan bebas langkah manual dari laptop.

Registry dan ECR

Login ke ghcr.io atau ECR, push tag semver, dan referensikan via image@sha256: untuk image yang immutable di produksi.

ECS Fargate dan RDS

Jalankan container tanpa server, sambungkan ke Postgres terkelola RDS, dan atur scaling tanpa menyentuh OS host.

Untuk runtime, ingat kembali pilihan base image ramping seperti distroless dari Google: image kecil non-root mempercepat pull di CI dan ECS sekaligus mengecilkan permukaan serangan. Referensi perintah dan praktik terbaik lain selalu bisa kamu cek di docs.docker.com.

📝Course ini fondasi, deploy AWS di jalur lanjutan

Tujuan course ini menanamkan fondasi container yang kokoh. Detail end-to-end ECR, ECS Fargate, dan RDS dibahas tuntas di jalur deploy lanjutan.

17

Ringkasan dan Poin Penting

Checklist Docker untuk backend Go

Kamu sekarang bisa mengubah biner Go menjadi container yang ramping, aman, dan reproducible, lalu menjalankannya sebagai bagian dari stack multi-service.

Mari petakan ulang perjalanan ini. Kita mulai dari membedakan image (cetakan immutable) dan container (instance yang berjalan), lalu menulis Dockerfile yang sadar cache layer. Multi-stage memisahkan toolchain build dari runtime sehingga image akhir hanya berisi biner dan sertifikat. Config masuk lewat env, bukan hardcode. Networking mengandalkan DNS antar service di user-defined network. Volume menjaga data Postgres tetap hidup melewati restart. Compose merangkai semuanya, dengan healthcheck menggerbangi urutan start. Terakhir, tag dan registry membuat image bisa dibagikan, sementara non-root dan scanning menjaga keamanan.

Yang Wajib Menempel

  • Image adalah cetakan immutable; container adalah instance berjalan dari image, dan registry tempat image dibagikan.
  • Urutkan Dockerfile dari yang jarang berubah ke yang sering: COPY go.mod go.sum lalu go mod download sebelum COPY . ..
  • Multi-stage plus CGO_ENABLED=0 menghasilkan biner statis yang muat di distroless/static-debian12, kecil dan tanpa shell.
  • Config selalu lewat env (environment/env_file); jangan hardcode kredensial atau alamat ke dalam image.
  • Di dalam container, localhost adalah container itu sendiri; service lain dipanggil lewat nama via DNS user-defined network.
  • Named volume menjaga data stateful (Postgres) bertahan lintas docker compose down dan restart.
  • Compose plus healthcheck dan depends_on: condition: service_healthy memastikan API start setelah DB benar-benar siap.
  • docker compose logs, exec, dan flag resource (--memory, --cpus) adalah alat debug dan pembatas dasar.
  • Pin tag semver dan referensi digest untuk deploy reproducible; jalankan sebagai non-root dan pindai dengan docker scout atau trivy.
💡Aturan emas image produksi

Image akhir yang baik: kecil, non-root, tanpa shell bila bisa, bertag immutable, dan tidak membawa satu pun secret di dalam layer-nya.

Tiga arah langkah berikutnya

CI/CD

Pindahkan build, test, dan push image ke pipeline otomatis agar setiap merge menghasilkan artefak yang konsisten dan teruji.

AWS ECR dan ECS

Simpan image di registry ECR, lalu jalankan container di ECS Fargate yang tersambung ke Postgres terkelola RDS.

Observability dan scaling

Tambahkan metrik, log terpusat, dan health probe agar container bisa di-scale dan dipantau dengan percaya diri.

Dari sini, lanjutkan ke Docker Compose tingkat lanjut untuk environment dev yang lebih kaya, lalu rakit CI pipeline yang membangun dan menguji image setiap commit. Setelah itu, bawa skincare-api ke produksi nyata lewat AWS ECR sebagai registry dan ECS Fargate sebagai runtime, dengan RDS sebagai database. Fondasi container yang kamu kuasai di sini adalah tiket masuk ke seluruh jalur deploy itu.