Web Artisan
Beranda

Progress belajar

Modul 66 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 8 · AWS Deployment

S3 dan CloudFront untuk
Gambar Produk

Pindahkan byte gambar keluar dari API dan database, lalu sajikan lewat CDN privat yang cepat dan aman.

Bahasa: Go 1.26AWS S3 & CloudFront~70 menit baca
01

Kenapa Gambar Tidak Masuk Database

Di React, upload terasa seperti mengirim File. Di backend production, keputusan utamanya adalah ke mana byte itu pergi.

Database PostgreSQL hebat untuk data relasional: nama produk, harga, stok, status. Ia buruk untuk menyimpan byte gambar.

Di katalog skincare, satu foto produk .webp bisa 200 KB sampai beberapa MB, jauh lebih besar daripada baris metadata produknya. Kalau kamu menyimpan blob biner di Postgres, setiap backup membengkak, setiap SELECT * berisiko menyeret byte gambar, dan replikasi jadi berat. Object storage seperti Amazon S3 dirancang khusus untuk file besar, dan CDN seperti Amazon CloudFront dirancang untuk menyajikannya cepat ke seluruh dunia.

object storage

Penyimpanan yang menampung file sebagai object di dalam bucket, masing-masing punya key unik (mis. products/uuid/main.webp), metadata, dan permission terpisah dari database aplikasi.

🌉Jembatan: dari Storage::put() Laravel ke upload langsung

Di Laravel kamu mungkin memanggil Storage::put() dari controller, jadi file fisik melewati PHP. Di desain ini controller Go tidak menyentuh byte gambar sama sekali, ia hanya menerbitkan izin sementara agar browser upload langsung ke S3.

JS / Laravel: upload lewat server
  • Frontend kirim file ke API, API baca file ke memori, lalu API simpan file.
  • Sederhana, tetapi API jadi bottleneck bandwidth dan boros memori saat file besar.
Go production: upload langsung ke S3
  • API hanya menerbitkan pre-signed URL, browser PUT file langsung ke S3.
  • API tetap memegang otorisasi dan metadata, tetapi tidak pernah buffer byte gambar.

Pembagian tanggung jawab inilah inti modul: Go API mengatur siapa boleh upload, product ID mana yang valid, dan object key apa yang sah. S3 menyimpan byte. CloudFront menyajikan dengan latency rendah. Tiap pihak melakukan satu hal yang ia paling jago.

02

Pembagian Data: Metadata di Postgres, Byte di S3

Postgres menyimpan referensi, S3 menyimpan isi. Keduanya saling rujuk lewat object key.

Aturan tunggal yang menyederhanakan semua keputusan berikutnya: metadata di PostgreSQL, file fisik di S3, jangan pernah blob biner di Postgres.

Tabel product_images cukup menyimpan kolom kecil yang murah di-query: id, product_id, s3_key, content_type, size_bytes, cdn_url, dan created_at. Byte aslinya hidup di S3 di bawah key yang sama dengan s3_key. Frontend tidak perlu tahu nama bucket atau region, ia hanya menerima cdn_url dan me-render-nya.

erDiagram
  PRODUCTS ||--o{ PRODUCT_IMAGES : "punya"
  PRODUCTS {
    uuid id PK
    text name
    bigint price_rupiah
  }
  PRODUCT_IMAGES {
    bigserial id PK
    uuid product_id FK
    text s3_key
    text content_type
    bigint size_bytes
    text cdn_url
    timestamptz created_at
  }

Gambar 1. Satu produk punya banyak gambar. Baris hanya memegang referensi (s3_key, cdn_url), bukan byte.

📝Kenapa simpan size_bytes dan content_type

Keduanya berguna untuk audit, validasi pasca-upload, dan menyetel header Content-Type yang benar saat CloudFront menyajikan gambar. Mengisinya dari hasil HeadObject membuat metadata kamu tepercaya, bukan menebak.

🌉Jembatan: kolom path di Laravel vs s3_key

Di Laravel kamu mungkin menyimpan kolom image_path relatif ke disk. Di sini s3_key adalah konsep yang sama, tetapi ia menunjuk ke object di S3, dan cdn_url adalah URL publik final yang dirender frontend. Pisahkan keduanya: s3_key untuk operasi backend, cdn_url untuk konsumsi frontend.

03

Arsitektur Upload dan Serve

Ada dua alur berbeda: admin upload gambar, customer membaca gambar. Keduanya tidak melewati jalur yang sama.

Pisahkan dua alur sejak awal. Alur tulis (upload) butuh otorisasi admin dan pre-signed URL. Alur baca (serve) cukup CDN publik di depan bucket privat.

sequenceDiagram
  participant Admin as Admin Frontend
  participant API as Go API (ECS)
  participant S3 as S3 (private)
  participant DB as PostgreSQL
  participant CF as CloudFront
  participant User as Customer Browser

  Admin->>API: POST /v1/admin/products/:id/images/upload-url
  API->>API: Validasi admin, productID, filename, content type
  API-->>Admin: pre-signed PUT URL + s3_key + cdn_url
  Admin->>S3: PUT byte gambar langsung ke S3
  Admin->>API: POST /v1/admin/products/:id/images (commit metadata)
  API->>S3: HeadObject (konfirmasi object ada)
  API->>DB: INSERT product_images (s3_key, cdn_url, size, type)
  User->>CF: GET https://cdn.../products/:id/main.webp
  CF->>S3: Fetch via OAC bila cache miss
  CF-->>User: Image dari edge cache

Gambar 2. API hanya mengatur izin dan metadata. Byte bergerak browser ke S3 (upload) dan S3 ke edge ke browser (serve), tidak pernah lewat API.

API tetap kecil

Request upload besar tidak melewati container API, jadi CPU dan memori ECS stabil walau banyak gambar diunggah.

Bucket tetap privat

Object S3 tidak pernah dibuka publik. Customer membaca gambar hanya lewat CloudFront.

URL stabil

Database menyimpan cdn_url, bukan URL S3 internal atau pre-signed URL yang berumur pendek.

POST /v1/admin/products/:productID/images/upload-url Terbitkan pre-signed upload URL untuk admin
POST /v1/admin/products/:productID/images Commit metadata gambar setelah upload sukses
GET /v1/products Customer menerima cdn_url dari CDN di response produk
⚠️Dua endpoint, bukan satu

Godaan umum adalah membuat satu endpoint yang menerima file lalu mengurus semuanya. Itu mengembalikan API ke posisi bottleneck. Pertahankan dua langkah: terbitkan izin, lalu catat metadata setelah upload selesai.

04

S3 Bucket, Object Key, dan Block Public Access

Bucket adalah container, object key adalah path. Bucket baru default privat, dan biarkan begitu.

S3 punya namespace datar. Yang kamu sebut folder sebenarnya hanyalah prefix di dalam object key.

Bucket adalah container dengan nama unik global. Object key adalah identifier object di dalam bucket, misalnya products/2f2c7b2a/main.webp. Tidak ada folder fisik, prefix products/ hanya konvensi penamaan yang membuat key mudah diberi IAM policy dan mudah dianalisis. Untuk proyek skincare, pakai pola products/<product-id>/<filename>.

Konvensi object key
products/2f2c7b2a/main.webp products/2f2c7b2a/gallery-01.webp products/2f2c7b2a/gallery-02.webp
Block Public Access

Setelan bucket yang memblokir akses publik. Untuk bucket baru, AWS mengaktifkannya secara default dan menyetel Object Ownership ke Bucket owner enforced (ACL nonaktif). Bucket bersifat privat sejak lahir.

🌉Jembatan: public/ di Laravel vs bucket privat S3

Di Laravel folder public/ memang dibuat untuk diakses langsung. Di S3 production, kebalikannya: bucket gambar tetap privat, dan akses publik hanya datang lewat CloudFront. Jangan membuka bucket publik hanya supaya gambar tampil di website.

Bucket privat

Block Public Access tetap ON. Mencegah akses langsung ke origin dan menjaga seluruh kontrol akses di CloudFront.

Prefix products/

Memudahkan IAM policy yang dibatasi ke satu prefix dan analisis lewat S3 Inventory.

Filename disanitasi

Mencegah path traversal, karakter aneh, dan object key yang sulit di-cache CDN.

Ekstensi whitelist

Membatasi upload ke format gambar yang memang didukung frontend (jpg, png, webp).

⚠️Jangan percaya filename dari browser

Nama seperti ../../secret.png harus berubah jadi nama aman atau ditolak. Object key adalah bagian dari boundary keamanan, bukan sekadar label tampilan.

05

Pre-signed Upload URL dari Go API

URL sementara bertanda tangan IAM milik API, berisi izin terbatas untuk satu operasi ke satu key.

Pre-signed URL bukan permission permanen. Ia URL berumur pendek yang ditandatangani credential API, mengizinkan satu PUT ke satu object key, lalu kedaluwarsa.

API membuat URL untuk PutObject ke key tertentu dengan masa berlaku pendek (misalnya 5 menit). Browser lalu melakukan PUT langsung ke URL itu, dengan header Content-Type yang sama persis seperti saat URL dibuat. Tanpa kredensial AWS apa pun di sisi klien.

📝SDK aws-sdk-go-v2

Client biasa dibuat dengan s3.NewFromConfig(cfg). Untuk presign, bungkus dengan s3.NewPresignClient(client), lalu panggil PresignPutObject. Hasilnya *v4.PresignedHTTPRequest dengan field URL, Method, dan SignedHeader.

internal/productimage/signer.go
package productimage import ( "context" "errors" "fmt" "mime" "path" "strings" "time" "unicode" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" ) var ErrInvalidImage = errors.New("invalid product image") type Signer struct { bucket string cdnBaseURL string presigner *s3.PresignClient } func NewSigner(cfg aws.Config, bucket, cdnBaseURL string) *Signer { client := s3.NewFromConfig(cfg) return &Signer{ bucket: bucket, cdnBaseURL: strings.TrimRight(cdnBaseURL, "/"), presigner: s3.NewPresignClient(client), } } type CreateUploadURLInput struct { ProductID string Filename string ContentType string } type UploadURL struct { Method string `json:"method"` UploadURL string `json:"upload_url"` Headers map[string]string `json:"headers"` S3Key string `json:"s3_key"` CDNURL string `json:"cdn_url"` ContentType string `json:"content_type"` ExpiresIn int `json:"expires_in"` } func (s *Signer) CreateUploadURL(ctx context.Context, in CreateUploadURLInput) (UploadURL, error) { productID, err := safeSegment(in.ProductID) if err != nil { return UploadURL{}, err } filename, err := safeFilename(in.Filename) if err != nil { return UploadURL{}, err } if !allowedImageContentType(in.ContentType) { return UploadURL{}, ErrInvalidImage } s3Key := fmt.Sprintf("products/%s/%s", productID, filename) expires := 5 * time.Minute request, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(s3Key), ContentType: aws.String(in.ContentType), }, s3.WithPresignExpires(expires)) if err != nil { return UploadURL{}, fmt.Errorf("presign put object: %w", err) } return UploadURL{ Method: request.Method, UploadURL: request.URL, Headers: map[string]string{"Content-Type": in.ContentType}, S3Key: s3Key, CDNURL: s.cdnBaseURL + "/" + s3Key, ContentType: in.ContentType, ExpiresIn: int(expires.Seconds()), }, nil } func safeSegment(value string) (string, error) { value = strings.TrimSpace(value) if value == "" || strings.ContainsAny(value, `/\`) { return "", ErrInvalidImage } return value, nil } func safeFilename(filename string) (string, error) { filename = strings.ReplaceAll(filename, `\`, "/") base := path.Base(filename) if base == "." || base == "/" || base == "" { return "", ErrInvalidImage } ext := strings.ToLower(path.Ext(base)) if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" { return "", ErrInvalidImage } stem := strings.TrimSuffix(base, path.Ext(base)) var b strings.Builder for _, r := range stem { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): b.WriteRune(unicode.ToLower(r)) case r == '-' || r == '_': b.WriteRune(r) case unicode.IsSpace(r): b.WriteByte('-') } } if b.Len() == 0 { return "", ErrInvalidImage } return b.String() + ext, nil } func allowedImageContentType(contentType string) bool { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil { return false } switch mediaType { case "image/jpeg", "image/png", "image/webp": return true default: return false } }

Kode ini menjaga tiga boundary. Pertama, productID tidak boleh menjadi path bebas (safeSegment menolak garis miring). Kedua, filename dibersihkan agar object key konsisten dan tidak bisa keluar dari prefix. Ketiga, content type dibatasi agar endpoint ini tidak berubah jadi pintu upload file arbitrer.

⚠️Content-Type harus konsisten saat PUT

Header Content-Type saat browser melakukan PUT wajib sama persis dengan yang ikut ditandatangani saat presign. Beda sedikit dan S3 menolak dengan SignatureDoesNotMatch. Kirim balik headers ke frontend dan pakai apa adanya.

internal/productimage/handler.go
package productimage import ( "encoding/json" "errors" "net/http" "github.com/go-chi/chi/v5" ) type PresignHandler struct { signer *Signer } func NewPresignHandler(signer *Signer) *PresignHandler { return &PresignHandler{signer: signer} } func (h *PresignHandler) Create(w http.ResponseWriter, r *http.Request) { var req struct { Filename string `json:"filename"` ContentType string `json:"content_type"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json body", http.StatusBadRequest) return } productID := chi.URLParam(r, "productID") result, err := h.signer.CreateUploadURL(r.Context(), CreateUploadURLInput{ ProductID: productID, Filename: req.Filename, ContentType: req.ContentType, }) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidImage) { status = http.StatusBadRequest } http.Error(w, http.StatusText(status), status) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(result) } type CommitHandler struct { committer *Committer } func NewCommitHandler(committer *Committer) *CommitHandler { return &CommitHandler{committer: committer} } func (h *CommitHandler) Create(w http.ResponseWriter, r *http.Request) { var req struct { S3Key string `json:"s3_key"` AltText string `json:"alt_text"` SortOrder int `json:"sort_order"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json body", http.StatusBadRequest) return } productID := chi.URLParam(r, "productID") img, err := h.committer.Commit(r.Context(), productID, req.S3Key, req.AltText, req.SortOrder) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidImage) { status = http.StatusBadRequest } http.Error(w, http.StatusText(status), status) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(img) }
Terminal
curl -X POST http://localhost:8080/v1/admin/products/2f2c7b2a/images/upload-url \ -H 'Authorization: Bearer ADMIN_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{"filename":"Wardah Brightening Toner.webp","content_type":"image/webp"}'
Terminal
curl -X PUT 'https://bucket.s3.ap-southeast-1.amazonaws.com/products/2f2c7b2a/wardah-brightening-toner.webp?X-Amz-Signature=...' \ -H 'Content-Type: image/webp' \ --data-binary '@wardah-brightening-toner.webp'
💡Kenapa API tidak menerima file

Tugas API adalah otorisasi dan metadata, bukan transfer byte. S3 memang dibuat untuk menerima object besar dengan throughput tinggi. Biarkan ia melakukan bagiannya.

06

Commit Metadata Setelah Upload

Upload sukses ke S3 tidak otomatis tercatat di database. Langkah kedua menutup loop ini.

Setelah browser selesai PUT ke S3, ada satu langkah lagi: frontend memanggil API untuk mencatat metadata. Di sinilah baris product_images lahir.

API tidak boleh percaya begitu saja bahwa upload benar terjadi. Sebelum menulis baris, panggil HeadObject untuk memastikan object benar ada di key yang diklaim, sekaligus mengambil ContentLength dan ContentType yang sebenarnya. Ini mengubah metadata dari “kata frontend” menjadi “fakta dari S3”.

flowchart LR
  A["Frontend selesai PUT ke S3"] --> B["POST /images (s3_key)"]
  B --> C["API HeadObject(s3_key)"]
  C -->|object ada| D["INSERT product_images"]
  C -->|NotFound| E["400 upload belum selesai"]
  D --> F["201 + cdn_url"]

Gambar 3. HeadObject sebagai gerbang verifikasi sebelum metadata di-commit ke Postgres.

internal/productimage/commit.go
package productimage import ( "context" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type Committer struct { bucket string cdnBaseURL string s3Client *s3.Client repo *Repository } func NewCommitter(cfg aws.Config, bucket, cdnBaseURL string, repo *Repository) *Committer { return &Committer{ bucket: bucket, cdnBaseURL: strings.TrimRight(cdnBaseURL, "/"), s3Client: s3.NewFromConfig(cfg), repo: repo, } } func (c *Committer) Commit(ctx context.Context, productID, s3Key, altText string, sortOrder int) (Image, error) { head, err := c.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(s3Key), }) if err != nil { var notFound *types.NotFound if errors.As(err, &notFound) { return Image{}, ErrInvalidImage } return Image{}, fmt.Errorf("head object: %w", err) } img := Image{ ProductID: productID, S3Key: s3Key, CDNURL: c.cdnBaseURL + "/" + s3Key, ContentType: aws.ToString(head.ContentType), SizeBytes: aws.ToInt64(head.ContentLength), AltText: altText, SortOrder: sortOrder, } if err := c.repo.Insert(ctx, &img); err != nil { return Image{}, fmt.Errorf("insert product image: %w", err) } return img, nil }
⚠️Tanpa HeadObject, metadata bisa berbohong

Frontend yang nakal atau bug bisa memanggil commit tanpa benar-benar upload. Tanpa verifikasi, kamu menyimpan baris yang menunjuk ke object yang tidak ada, dan customer melihat gambar rusak. HeadObject menutup celah ini dengan murah.

🌉Jembatan: dari validasi request Laravel ke verifikasi sumber

Di Laravel kamu memvalidasi input request dengan FormRequest. Di sini validasi tidak cukup di level request, karena sumber kebenaran ada di S3. Pola “verify against the source” ini khas integrasi cloud: jangan percaya klaim, tanyakan ke sistem yang memegang fakta.

07

CloudFront dan Origin Access Control

CloudFront jadi domain publik untuk gambar. OAC membuat bucket tetap privat sambil tetap bisa dibaca CDN.

S3 menyimpan object. CloudFront membuatnya cepat dari edge location yang dekat dengan customer, tanpa pernah membuka bucket ke publik.

CloudFront distribution menjadi origin tunggal yang menghadap viewer, misalnya https://cdn.tokoskincare.com/products/2f2c7b2a/main.webp. Origin-nya adalah bucket S3 privat. Yang menjembatani keduanya secara aman adalah Origin Access Control.

Origin Access Control

OAC adalah mekanisme CloudFront untuk mengakses origin S3 dengan menandatangani request (SigV4) atas namanya. OAC menggantikan OAI legacy dan mendukung semua region serta SSE-KMS. Dengan OAC, viewer hanya bisa membaca object lewat distribution yang kamu tentukan.

flowchart LR
  User["Customer Browser"] -->|HTTPS GET cdn_url| CF["CloudFront Distribution"]
  CF -->|cache hit| User
  CF -->|cache miss, OAC SigV4| S3[("S3 bucket privat")]
  S3 -->|s3:GetObject via bucket policy| CF
  Direct["Akses langsung ke URL S3"] -.->|403 AccessDenied| S3

Gambar 4. Hanya CloudFront yang bisa membaca bucket. Akses langsung ke URL S3 ditolak karena Block Public Access tetap ON.

Bucket policy memberi izin baca ke service principal cloudfront.amazonaws.com, dibatasi Condition AWS:SourceArn ke ARN distribution tertentu. Dengan begitu, bukan hanya CloudFront secara umum yang boleh, tetapi distribution kamu yang spesifik.

infra/s3/cloudfront-oac-bucket-policy.json
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowCloudFrontServicePrincipalReadOnly", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::skincare-product-images-prod/products/*", "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E123EXAMPLE" } } } ] }
Buat bucket privat

Aktifkan Block Public Access dan biarkan Object Ownership di Bucket owner enforced.

Buat distribution

Pakai bucket S3 sebagai origin, buat OAC, dan pilih Sign requests (always) agar CloudFront ke S3 selalu HTTPS plus SigV4.

Pasang bucket policy

Beri izin s3:GetObject ke service principal CloudFront, dibatasi SourceArn ke distribution kamu.

Pasang HTTPS dan domain

Set viewer protocol policy ke Redirect HTTP to HTTPS. Untuk domain kustom cdn.tokoskincare.com, siapkan sertifikat ACM di region us-east-1.

Simpan cdn_url

API menyimpan https://<domain>/<s3_key> di database, bukan URL S3 maupun pre-signed URL.

⚠️Sertifikat ACM untuk CloudFront wajib us-east-1

Custom domain CloudFront hanya menerima sertifikat ACM dari region us-east-1, berapa pun region bucket kamu. Membuat sertifikat di region lain akan membuatnya tidak muncul saat memilih alternate domain name.

08

IAM Task Role ECS yang Minimum

Container API memakai task role untuk memanggil AWS API, bukan access key di environment variable.

Di local kamu mungkin pakai profil AWS. Di ECS production, jangan inject access key statis. Beri task role dengan izin sekecil mungkin.

ECS punya dua role yang sering tertukar. Task execution role dipakai agent ECS untuk menarik image dan mengirim log ke CloudWatch. Task role dipakai aplikasi Go di dalam container untuk akses layanan AWS seperti S3. Presign dan HeadObject memakai task role.

🌉Jembatan: dari AWS_ACCESS_KEY di .env ke IAM role

Di Laravel kamu mungkin menaruh AWS_ACCESS_KEY_ID di .env. Di ECS, hapus kebiasaan itu. config.LoadDefaultConfig otomatis mengambil credential sementara dari task role lewat metadata endpoint, tanpa secret statis yang bisa bocor di git.

infra/iam/product-images-task-role-policy.json
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowProductImageObjectAccess", "Effect": "Allow", "Action": [ "s3:PutObject", "s3:GetObject" ], "Resource": "arn:aws:s3:::skincare-product-images-prod/products/*" } ] }

Policy ini sengaja tidak memberi s3:DeleteObject, s3:ListBucket, atau akses ke bucket lain. Penghapusan gambar lebih baik jadi operasi admin terpisah dengan policy lebih ketat dan audit log, sehingga credential API yang bocor tidak bisa menghapus seluruh katalog.

⚠️Jangan campur task role dan execution role

Kalau aplikasi Go gagal presign atau HeadObject dengan AccessDenied, cek task role. Kalau ECS gagal pull image atau kirim log, cek task execution role. Dua kegagalan ini berbeda akar dan berbeda tempat memperbaikinya.

📝HeadObject butuh GetObject

HeadObject di SDK menggunakan izin s3:GetObject, bukan izin terpisah. Policy minimal PutObject plus GetObject di atas sudah cukup untuk presign upload sekaligus verifikasi commit.

09

Hands-on: Integrasi ke Proyek Skincare

Letakkan modul gambar sebagai package kecil di sekitar domain product, bukan util global tanpa batas.

Sekarang kita rakit semuanya: migration, repository, signer, committer, dan wiring route admin di belakang JWT.

Struktur file yang ditambahkan
  • cmd/
  • api/
  • main.go wiring route dan dependency AWS SDK
  • internal/
  • productimage/
  • signer.go presign PutObject URL
  • commit.go HeadObject lalu simpan metadata
  • handler.go endpoint admin presign + commit
  • repository.go akses tabel product_images
  • model.go struct Image
  • product/
  • handler.go response product membawa cdn_url
  • db/
  • migrations/
  • 000021_create_product_images.sql
  • infra/
  • iam/
  • product-images-task-role-policy.json
  • s3/
  • cloudfront-oac-bucket-policy.json
db/migrations/000021_create_product_images.sql
CREATE TABLE product_images ( id BIGSERIAL PRIMARY KEY, product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, s3_key TEXT NOT NULL, content_type TEXT NOT NULL, size_bytes BIGINT NOT NULL, cdn_url TEXT NOT NULL, alt_text TEXT NOT NULL DEFAULT '', sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (product_id, s3_key) ); CREATE INDEX idx_product_images_product_sort ON product_images (product_id, sort_order, id);
internal/productimage/model.go
package productimage type Image struct { ID int64 `json:"id"` ProductID string `json:"product_id"` S3Key string `json:"s3_key"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` CDNURL string `json:"cdn_url"` AltText string `json:"alt_text"` SortOrder int `json:"sort_order"` }
internal/productimage/repository.go
package productimage import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) type Repository struct { db *pgxpool.Pool } func NewRepository(db *pgxpool.Pool) *Repository { return &Repository{db: db} } func (r *Repository) Insert(ctx context.Context, img *Image) error { const query = ` INSERT INTO product_images (product_id, s3_key, content_type, size_bytes, cdn_url, alt_text, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id ` err := r.db.QueryRow(ctx, query, img.ProductID, img.S3Key, img.ContentType, img.SizeBytes, img.CDNURL, img.AltText, img.SortOrder, ).Scan(&img.ID) if err != nil { return fmt.Errorf("insert product image: %w", err) } return nil } func (r *Repository) ListByProduct(ctx context.Context, productID string) ([]Image, error) { const query = ` SELECT id, product_id, s3_key, content_type, size_bytes, cdn_url, alt_text, sort_order FROM product_images WHERE product_id = $1 ORDER BY sort_order, id ` rows, err := r.db.Query(ctx, query, productID) if err != nil { return nil, fmt.Errorf("list product images: %w", err) } defer rows.Close() var images []Image for rows.Next() { var img Image if err := rows.Scan( &img.ID, &img.ProductID, &img.S3Key, &img.ContentType, &img.SizeBytes, &img.CDNURL, &img.AltText, &img.SortOrder, ); err != nil { return nil, fmt.Errorf("scan product image: %w", err) } images = append(images, img) } return images, rows.Err() }
cmd/api/main.go
package main import ( "context" "log" "net/http" "os" "github.com/aws/aws-sdk-go-v2/config" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/kamu/skincare-backend/internal/productimage" ) func main() { ctx := context.Background() awsConfig, err := config.LoadDefaultConfig(ctx) if err != nil { log.Fatalf("load aws config: %v", err) } bucket := os.Getenv("PRODUCT_IMAGE_BUCKET") cdnBaseURL := os.Getenv("PRODUCT_IMAGE_CDN_BASE_URL") if bucket == "" || cdnBaseURL == "" { log.Fatal("PRODUCT_IMAGE_BUCKET and PRODUCT_IMAGE_CDN_BASE_URL are required") } pool, err := pgxpool.New(ctx, os.Getenv("DB_URL")) if err != nil { log.Fatalf("connect db: %v", err) } defer pool.Close() repo := productimage.NewRepository(pool) signer := productimage.NewSigner(awsConfig, bucket, cdnBaseURL) committer := productimage.NewCommitter(awsConfig, bucket, cdnBaseURL, repo) imgHandler := productimage.NewPresignHandler(signer) commitHandler := productimage.NewCommitHandler(committer) r := chi.NewRouter() r.Route("/v1/admin", func(r chi.Router) { // r.Use(authAdmin) // JWT + role admin sebelum endpoint gambar. r.Post("/products/{productID}/images/upload-url", imgHandler.Create) r.Post("/products/{productID}/images", commitHandler.Create) }) log.Println("api listening on :8080") if err := http.ListenAndServe(":8080", r); err != nil { log.Fatal(err) } }
Terminal
export PRODUCT_IMAGE_BUCKET=skincare-product-images-dev export PRODUCT_IMAGE_CDN_BASE_URL=https://d111111abcdef8.cloudfront.net export AWS_REGION=ap-southeast-1 export DB_URL='postgres://app:secret@localhost:5432/skincare?sslmode=disable' go run ./cmd/api
Buat bucket privat

Nama unik global, mis. skincare-product-images-dev-<accountid>. Block Public Access ON.

Deploy CloudFront

Pasang bucket privat sebagai origin, buat OAC, dan terapkan bucket policy SourceArn.

Pasang IAM policy

Attach policy S3 minimal ke ECS task role API, bukan ke user pribadi atau access key statis.

Lindungi route admin

Endpoint presign dan commit harus di belakang middleware JWT dengan role admin.

Jalankan alur penuh

Presign, PUT ke S3, commit metadata, lalu cek cdn_url benar tampil lewat CloudFront.

🧴Analogi toko skincare

API seperti kasir yang memberi nomor loker upload dan mencatat barang masuk. S3 adalah gudang file. CloudFront adalah etalase cepat yang dilihat customer di depan toko.

10

Cache, Invalidation, dan Cache-Busting

Gambar produk sangat cacheable. Masalahnya muncul saat gambar di key yang sama berubah.

CloudFront menyimpan salinan di edge sesuai TTL. Itu bagus untuk gambar yang jarang berubah, tetapi menjebak saat admin mengganti foto produk di key yang sama.

Gambar produk punya cacheability tinggi, jadi set TTL panjang. Saat admin mengganti main.webp dengan foto baru di key yang sama, edge cache masih menyajikan foto lama sampai TTL habis. Ada dua cara menanganinya, dan keduanya punya tempat berbeda.

Invalidation
  • Buat invalidation /products/123/* untuk menghapus salinan edge.
  • Berguna sesekali, tetapi ada kuota gratis terbatas dan biaya bila sering.
Cache-busting (key baru)
  • Upload ke key baru, mis. main-v2.webp, lalu update cdn_url di DB.
  • URL lama tetap valid, URL baru langsung fresh tanpa menyentuh cache lama.
💡Default ke cache-busting

Untuk file yang berubah, lebih murah dan lebih aman memakai key baru daripada invalidasi terus-menerus. Simpan versi di nama key atau tambahkan hash konten, lalu cukup ubah cdn_url di baris metadata.

Pola cache-busting per versi
products/2f2c7b2a/main-1718000000.webp # versi awal products/2f2c7b2a/main-1718600000.webp # foto diganti, key baru, cdn_url baru
📝Resizing dan thumbnail

Untuk beberapa ukuran (thumbnail, detail), mulai dari proses async yang membuat varian saat upload, masing-masing di key sendiri. Untuk kebutuhan lanjut pertimbangkan CloudFront Functions atau Lambda@Edge, tetapi jangan tambahkan kompleksitas ini sebelum bisnis benar-benar memintanya.

11

Jebakan Umum

Sebagian besar bug upload gambar bukan dari AWS yang rumit, tetapi dari boundary yang terlalu longgar.

Pola yang sama berulang di tim yang baru pindah ke object storage. Kenali enam jebakan ini sebelum mereka menggigit di production.

Menyimpan file di database

Postgres membengkak, backup makin berat, dan query katalog tanpa sengaja menyeret byte gambar.

Bucket publik tanpa CDN

Cepat untuk demo, buruk untuk production karena akses origin sulit dikontrol dan performa global tidak stabil.

Menyimpan pre-signed URL

URL ini berumur pendek dan berisi signature, jadi jangan pernah disimpan sebagai cdn_url.

Content-Type tidak konsisten

Header saat PUT harus sama persis dengan yang ditandatangani saat presign, atau S3 menolak.

Filename mentah dari user

Nama file bisa membawa path, spasi aneh, unicode tak terduga, atau ekstensi palsu.

IAM terlalu luas

Izin s3:* di semua bucket memperbesar blast radius saat credential bocor.

⚠️Upload sukses belum berarti gambar valid

S3 menerima object apa pun sesuai request, termasuk file 0 byte atau gambar rusak. Validasi dimensi, ukuran, dan transformasi sebaiknya di pipeline lanjutan saat bisnis mulai butuh kontrol kualitas gambar. HeadObject saja hanya membuktikan object ada, bukan bahwa isinya gambar yang baik.

⚠️Jangan jalankan invalidasi di hot path

Memanggil invalidation di setiap update gambar memakan kuota dan menambah latency. Default ke key baru, dan cadangkan invalidation untuk koreksi sesekali.

12

Ringkasan & Poin Penting

Backend skincare kini punya pola production untuk gambar produk tanpa membebani API dan database.

Kamu sudah memisahkan tanggung jawab dengan rapi: API mengatur izin dan metadata, S3 menyimpan byte, CloudFront menyajikan cepat dari bucket yang tetap privat.

Yang Wajib Menempel

  • Metadata di PostgreSQL, byte gambar di S3. Jangan pernah simpan blob biner di database.
  • Bucket S3 tetap privat dengan Block Public Access ON. Akses publik hanya lewat CloudFront plus OAC.
  • Pre-signed PutObject URL membuat browser upload langsung ke S3, jadi API tidak buffer file besar.
  • Setelah upload, commit metadata lewat langkah kedua, dan verifikasi dengan HeadObject sebelum menulis baris.
  • Simpan cdn_url (URL CloudFront permanen) di DB, bukan pre-signed URL atau URL S3 internal.
  • ECS task role cukup s3:PutObject plus s3:GetObject pada prefix products/*, bukan access key statis.
  • Untuk gambar yang berubah, pakai key baru (cache-busting), bukan invalidasi terus-menerus.
Pemetaan ke proyek dan langkah berikutnya

Modul ini melengkapi sisi media asset backend skincare. Katalog sekarang mengirim cdn_url yang cepat, aman, dan lepas dari filesystem container. Langkah berikutnya di Chapter 9: pasang observability CloudWatch untuk error presign, cache miss, dan akses CDN agar masalah gambar terdeteksi dini.

Progress disimpan lokal di browser ini.