← Semua pembelajaran / Laravel Nol → Enterprise
Fase 9 · Deploy di AWS

Image produksi & ECR

Image produksi yang baik hanya berisi apa yang dibutuhkan saat runtime. Semua yang dipakai untuk membangunnya — Composer dev, Node, kode sumber aset — tinggal di stage yang dibuang.

Sumber asli frankenphp.dev Resmi Rangkuman ~8 menit baca

Intisari

  • Multi-stage: satu stage untuk dependensi PHP, satu untuk aset frontend, satu untuk runtime.
  • Basis Debian, bukan Alpine — kedua runtime worker mode menyarankan itu.
  • php artisan optimize dijalankan saat build, bukan saat kontainer menyala.
  • Jalankan sebagai pengguna tanpa hak istimewa; filesystem read-only kecuali beberapa folder.
  • Beri tag image dengan SHA commit, bukan latest — supaya rollback bisa menunjuk versi persis.

Dockerfile

# syntax=docker/dockerfile:1

# ── Stage 1: dependensi PHP ────────────────────────────────────────────
FROM composer:2 AS vendor

WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
      --no-dev --no-scripts --no-autoloader \
      --prefer-dist --no-interaction

COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative --no-dev

# ── Stage 2: aset frontend ─────────────────────────────────────────────
FROM node:22-bookworm-slim AS aset

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build

# ── Stage 3: runtime ───────────────────────────────────────────────────
FROM dunglas/frankenphp:php8.4-bookworm AS runtime

RUN install-php-extensions \
      pdo_mysql redis intl opcache zip bcmath pcntl gd

COPY docker/php.ini      /usr/local/etc/php/conf.d/99-app.ini
COPY docker/Caddyfile    /etc/frankenphp/Caddyfile

WORKDIR /app

COPY --from=vendor /app                       /app
COPY --from=aset   /app/public/build          /app/public/build

# Cache dibangun SAAT BUILD — kontainer menyala tanpa pekerjaan tambahan
RUN php artisan config:clear \
 && php artisan route:cache \
 && php artisan view:cache \
 && php artisan event:cache

RUN chown -R www-data:www-data storage bootstrap/cache

USER www-data

ENV SERVER_NAME=":8000"

EXPOSE 8000

HEALTHCHECK --interval=15s --timeout=3s --start-period=20s \
  CMD curl -fsS http://127.0.0.1:8000/up || exit 1

CMD ["php", "artisan", "octane:frankenphp", \
     "--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500", \
     "--log-level=info"]

Perhatikan apa yang tidak ikut ke image akhir. Composer, Node, node_modules, folder resources/js, dan seluruh dependensi --dev tinggal di stage sebelumnya. Hasilnya bukan cuma image yang lebih kecil dan lebih cepat ditarik saat autoscaling, tapi juga permukaan serangan yang jauh lebih sempit — tidak ada Node di dalam kontainer produksimu.

Kenapa config:cache tidak dijalankan saat build

RUN php artisan route:cache view:cache event:cache    # ← aman saat build
# config:cache TIDAK dijalankan di sini

Karena variabel environment belum ada saat build. Menjalankan config:cache di Dockerfile akan membekukan nilai kosong ke dalam image. Rute, view, dan event tidak bergantung pada environment, jadi aman di-cache saat build. Konfigurasi harus di-cache setelah environment disuntikkan — yaitu di entrypoint, atau tidak sama sekali (Laravel tetap membaca config/ dengan cepat kalau optimize:clear pernah dijalankan).

#!/bin/sh
# docker/entrypoint.sh — dijalankan setelah environment tersedia
set -e

php artisan config:cache

exec "$@"

Caddyfile untuk kontainer

{
	frankenphp {
		num_threads 8
		max_threads 24
	}
	# Tanpa TLS: ALB yang menanganinya
	auto_https off
	admin off
	servers {
		trusted_proxies static 10.0.0.0/8
	}
}

:8000 {
	root /app/public
	encode zstd gzip

	@statis path /build/* /favicon.ico /robots.txt
	file_server @statis

	php_server {
		try_files {path} index.php
		root /app/public
		resolve_root_symlink false
	}
}

php.ini produksi

memory_limit = 256M
expose_php = Off

opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0

realpath_cache_size = 4096K
realpath_cache_ttl = 600

upload_max_filesize = 32M
post_max_size = 32M

Membangun dan mendorong ke ECR

SHA=$(git rev-parse --short HEAD)
REPO=123456789012.dkr.ecr.ap-southeast-1.amazonaws.com/portal

aws ecr get-login-password --region ap-southeast-1 \
  | docker login --username AWS --password-stdin "$REPO"

docker buildx build \
  --platform linux/amd64 \
  --cache-from type=registry,ref=$REPO:cache \
  --cache-to   type=registry,ref=$REPO:cache,mode=max \
  -t "$REPO:$SHA" \
  -t "$REPO:latest" \
  --push .

Tag dengan SHA commit, bukan hanya latest. Task definition harus menunjuk ke tag yang spesifik. Kalau ia menunjuk latest, rollback jadi tidak berarti — latest hari ini bukan latest kemarin, dan kamu kehilangan kemampuan menunjuk "versi yang tadi berjalan baik". Untuk jaminan yang lebih kuat lagi, gunakan digest image.

Menyetel repositori ECR

# Pindai kerentanan otomatis setiap kali image didorong
aws ecr put-image-scanning-configuration \
  --repository-name portal --image-scanning-configuration scanOnPush=true

# Cegah tag ditimpa — image yang sudah ada tidak bisa berubah isinya
aws ecr put-image-tag-mutability \
  --repository-name portal --image-tag-mutability IMMUTABLE

Tambahkan juga lifecycle policy yang menyimpan sekitar 20 image terakhir dan menghapus sisanya. Tanpa itu, repositori ECR akan berisi ratusan image lama yang tetap ditagih penyimpanannya.

Membuat kontainer lebih aman

PraktikCara
Tidak berjalan sebagai rootUSER www-data
Filesystem read-onlyreadonlyRootFilesystem di task definition, plus volume tmpfs untuk storage/framework dan /tmp
Tanpa rahasia di dalam imageSemua lewat secrets di task definition
Pemindaian kerentananscanOnPush, plus Trivy di CI
Basis image diperbaruiBuild ulang berkala, bukan hanya saat kode berubah

Filesystem read-only butuh persiapan di Laravel. Aplikasi menulis ke storage/framework/{cache,sessions,views} dan storage/logs. Solusinya: view sudah di-cache saat build, session dan cache dipindahkan ke Redis, dan log diarahkan ke stderr. Setelah itu satu volume tmpfs kecil untuk storage/framework sudah cukup — dan kontainermu benar-benar tanpa keadaan.

Latihan: bangun image dengan Dockerfile di atas dan periksa ukurannya dengan docker images. Lalu jalankan docker run --read-only --tmpfs /app/storage/framework … dan cari tahu bagian mana yang masih gagal — itulah daftar tempat aplikasimu masih menulis ke disk.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.