Lewati ke konten

Deployment Asisten

Asisten TrainerSebagian terujiArti badge ini

Panduan ini dipakai oleh asisten trainer setelah koordinator memberikan akses IAM ke project kelompok GCP. Satu project dipakai oleh lima asisten. Karena itu, setiap asisten harus memakai slug unik pada Service Account, VM, GitHub connection, linked repository, trigger, image, alamat IP, dan DNS zone.

Panduan ini disusun berurutan dalam tiga tahap:

  1. Bagian 1 sampai 2, dari laptop. Fork repository, lengkapi berkas konfigurasi, commit, lalu push.
  2. Bagian 3 sampai 12, dari Cloud Shell dan VM. Buka project, buat Service Account, dan buat VM.
  3. Bagian 13 sampai 31, dari VM dan Cloud Shell. Clone fork, isi .env, jalankan Cloud Build, uji GeoServer, cadangkan data, pantau akses portal, daftarkan ke mesin pencari, lalu bersihkan resource dan hentikan VM.

Panduan ini sudah melewati satu pengujian penuh pada 11 September 2026 dengan satu email. Alur fork, VM, GitHub, Cloud Build, tiga container, halaman /portal, GeoServer, unggah shapefile, dan pembacaan layer lewat WFS semuanya berjalan. Bagian 24 tentang domain, DNS, dan HTTPS juga sudah lulus uji pada 12 September 2026, dengan sertifikat Let’s Encrypt untuk satu subdomain sungguhan dan halaman portal yang membalas 200 lewat HTTPS.

Yang belum diuji adalah lima VM yang menyala bersamaan di dalam satu project, undangan lima email beserta sepuluh role IAM, dan lima GitHub connection dalam satu project. Bagian 26 tentang pemantauan akses portal sudah diuji pada 13 September 2026: Nginx dimatikan pukul 11:48:02 WIB, incident dan email firing muncul sekitar 11:50 WIB, lalu email recovered untuk incident yang sama diterima setelah pemulihan. Durasi incident 8 menit 42 detik. Yang belum diuji itulah yang dicatat lewat workbook asisten trainer.

VM memakai spesifikasi default dari modul, yaitu e2-medium dengan heap GeoServer -Xmx2048m. Pengukuran 11 September 2026 menunjukkan spesifikasi ini lega, bukan mepet: GeoServer memakai 882 MiB dari batas 2048 MiB dan 2,3 GiB dari 3,8 GiB masih tersedia. Karena itu spesifikasi default dipertahankan tanpa dinaikkan.

Setelah panduan ini selesai:

  1. Fork berisi enam berkas konfigurasi: docker-compose.yml, nginx.conf, .env.example, .gitignore, scripts/check-config.mjs, dan scripts/periksa-nginx.mjs.
  2. Push ke branch main memicu Cloud Build yang membangun image dan menjalankannya di VM.
  3. API bersama dan Artifact Registry sudah tersedia di project kelompok.
  4. Service Account deployment dan VM dengan nama unik asisten sudah tersedia.
  5. Tiga container berjalan: nextjs_portal, geoserver_app, dan nginx_proxy.
  6. Geoportal dapat dibuka melalui path /portal.
  7. GeoServer dapat dibuka melalui path /geoserver/web tanpa slash di akhir.
  8. Data spasial dapat diunggah dan dibaca kembali melalui endpoint WFS.
  9. Jika diminta, domain asisten dapat diarahkan ke alamat IP VM.
  10. Folder geoserver-data dan berkas .env tersalin ke luar VM sebagai cadangan.
  11. Kesehatan tiga container, memori, dan ruang disk sudah diperiksa dan tercatat.
  12. Jika diminta, domain sudah terverifikasi di Google Search Console dan sitemapnya terkirim.

1. Fork repository dan siapkan berkas konfigurasi

Section titled “1. Fork repository dan siapkan berkas konfigurasi”

Bagian ini dikerjakan dari laptop sebelum menyentuh Google Cloud. Alasannya sederhana: repository instruktur belum memuat docker-compose.yml, nginx.conf, maupun .env.example, sedangkan Cloud Build membaca berkas-berkas itu dari fork. Kalau fork belum lengkap, build pertama akan gagal walaupun project GCP sudah benar.

Urutan yang benar adalah fork, sunting berkas di laptop, verifikasi, commit, push, baru lanjut ke bagian 4. Jangan membuat VM lebih dahulu, karena VM hanya menyalin hasil fork.

Enam berkas berikut disiapkan pada bagian ini, lalu diverifikasi pada bagian 2 dan di-commit pada bagian 3:

Berkas Fungsi
docker-compose.yml Tiga service: nextjs, geoserver, dan nginx
nginx.conf Rute /portal dan /geoserver/web
.env.example Contoh isi .env tanpa nilai rahasia
.gitignore Menambah /geoserver-data/ supaya data GeoServer tidak masuk Git
scripts/check-config.mjs Pemeriksa dua berkas YAML, jalan dengan Node.js saja
scripts/periksa-nginx.mjs Pemeriksa struktur nginx.conf, juga tanpa container
  1. Buka https://github.com/matiurari/personal-geoportal pada browser.
  2. Pilih Fork, lalu pilih akun GitHub asisten sebagai tujuan.
  3. Beri nama fork personal-geoportal-peserta supaya seluruh contoh perintah pada panduan ini cocok. Jika nama itu sudah dipakai repository lain di akun GitHub asisten, biarkan nama bawaan personal-geoportal dan sesuaikan URL clone pada langkah berikutnya.
  4. Pastikan branch default fork adalah main.

Repository public dapat difork tanpa izin khusus. Pastikan fork benar-benar berada di akun asisten, bukan di organisasi yang tidak dipakai untuk pelatihan.

Repository instruktur belum memuat docker-compose.yml, nginx.conf, maupun .env.example. Ketiga berkas itu hanya muncul sebagai teks pada modul, jadi asisten perlu membuatnya sendiri di fork. Enam berkas berikut wajib disiapkan sebelum Cloud Build dijalankan. Tanpa perubahan ini container geoserver_app tidak pernah dibuat dan GeoServer tidak bisa dibuka.

docker-compose.yml di root repository fork:

services:
nextjs:
image: ${NEXTJS_IMAGE:?Set NEXTJS_IMAGE in .env}
container_name: nextjs_portal
env_file:
- .env
depends_on:
- geoserver
networks:
- app-network
restart: unless-stopped
geoserver:
image: kartoza/geoserver:2.24.1
container_name: geoserver_app
environment:
- INITIAL_MEMORY=512m
- MAXIMUM_MEMORY=2048m
- GEOSERVER_ADMIN_USER=admin
# Diisi dari .env supaya kata sandi tidak tertulis di repository public.
- GEOSERVER_ADMIN_PASSWORD=${GEOSERVER_ADMIN_PASSWORD}
- GEOSERVER_CORS_ENABLED=true
- GEOSERVER_CORS_ALLOWED_ORIGINS=*
volumes:
- ./geoserver-data:/opt/geoserver/data_dir
networks:
- app-network
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
container_name: nginx_proxy
depends_on:
- nextjs
- geoserver
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge

Catatan dua variabel memori. Image kartoza/geoserver membaca INITIAL_MEMORY dan MAXIMUM_MEMORY, bukan JAVA_OPTS seperti yang tertulis di modul. MAXIMUM_MEMORY=2048m adalah heap default yang sengaja dipertahankan pada e2-medium 4 GB.

Catatan Nginx. Dua blok location memindahkan alamat tujuan ke dalam variabel sebelum proxy_pass, dan menambahkan resolver 127.0.0.11. Pola ini bukan pilihan gaya, melainkan syarat supaya Nginx mau start.

Nginx menyelesaikan nama service saat start, dan berhenti dengan host not found in upstream bila nama itu belum ada. Padahal urutan deployment ini sengaja menyalakan geoserver dan nginx lebih dahulu, sebelum image nextjs dibangun. Tanpa pola variabel, container nginx_proxy langsung keluar dengan kode 1, dan gejalanya di browser hanya Connection refused pada port 80, tanpa petunjuk apa pun tentang penyebabnya.

Dengan variabel, penyelesaian nama ditunda sampai ada permintaan. Jadi Nginx start dengan tenang, lalu membalas 502 hanya untuk permintaan yang tujuannya memang belum siap.

Alamat 127.0.0.11 adalah DNS internal Docker pada jaringan bridge buatan Compose. Jangan menggantinya dengan alamat lain.

Catatan kata sandi. Nilai ${GEOSERVER_ADMIN_PASSWORD} dibaca dari .env, bukan ditulis di berkas compose, supaya kata sandi tidak ikut terbit di repository public. Variabel ini membuat kata sandi admin ditentukan saat container pertama kali dijalankan, sehingga kata sandi bertahan setelah container di-restart. Jika variabelnya dibiarkan kosong, image kartoza membuat kata sandi acak baru pada setiap start dan menuliskannya ke log container.

nginx.conf di root repository fork:

server {
listen 80;
server_name _;
location = / {
return 302 /portal;
}
# Diarahkan langsung ke bentuk kanonik tanpa slash, karena GeoServer
# mengalihkan /geoserver/web/ ke /geoserver/web.
location = /geoserver {
return 302 /geoserver/web;
}
# Nama service di-resolve saat ada permintaan, bukan saat Nginx start.
# Tanpa pola ini, Nginx menolak start dengan "host not found in upstream"
# selama container nextjs belum ada. Padahal geoserver dan nginx sengaja
# dinyalakan lebih dahulu, sebelum image nextjs dibangun.
location /geoserver/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $geoserver_upstream http://geoserver:8080;
rewrite ^/geoserver/(.*)$ /geoserver/$1 break;
proxy_pass $geoserver_upstream;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
}
location / {
resolver 127.0.0.11 valid=10s ipv6=off;
set $nextjs_upstream http://nextjs:3000;
proxy_pass $nextjs_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Perhatikan pola set lalu proxy_pass pada variabel. Itu bukan gaya penulisan biasa, melainkan syarat supaya Nginx mau start sebelum container tujuannya ada. Penjelasannya ada pada catatan di bawah tabel berkas.

.env.example di root repository fork. Berkas ini menjadi contoh isi .env di VM, sehingga nilai asli tidak perlu ikut masuk Git. NEXTAUTH_URL dan BASE_URL memakai alamat yang sama, yaitu alamat IP eksternal VM atau domainnya, keduanya tanpa slash di akhir.

# Salin berkas ini menjadi .env di VM, lalu isi nilai aslinya di sana.
# Jangan pernah commit berkas .env yang sudah terisi.
# Diisi otomatis oleh Cloud Build pada deploy pertama.
#
# Sebelum build pertama, pakai image yang benar-benar ada sebagai nilai sementara,
# misalnya nginx:1.27-alpine. Jangan memakai nama karangan seperti belum-dibangun,
# karena compose akan mencoba menariknya dan gagal dengan pull access denied.
#
# Nilai kosong juga bermasalah: interpolasi berjalan sebelum service dipilih, jadi
# seluruh perintah docker compose gagal, termasuk yang hanya menyalakan geoserver.
NEXTJS_IMAGE=nginx:1.27-alpine
# Boleh dikosongkan untuk menguji build, Nginx, dan halaman publik.
# Login, Prisma, dan fitur katalog memerlukan database yang valid.
DATABASE_URL=
# Buat dua nilai acak yang berbeda dengan: openssl rand -hex 32
JWT_SECRET=
JWT_EXPIRES_IN=1h
NEXTAUTH_SECRET=
# Wajib memakai alamat IP eksternal VM atau domainnya, tanpa slash di akhir,
# contoh: NEXTAUTH_URL=http://IP_EKSTERNAL_VM/portal
NEXTAUTH_URL=
# Kata sandi admin GeoServer. Pakai openssl rand -hex 16 supaya hanya berisi huruf
# dan angka. Jangan memakai tanda dolar, karena compose membacanya sebagai variabel
# dan karakter setelahnya bisa hilang tanpa peringatan yang jelas.
GEOSERVER_ADMIN_PASSWORD=
AUTH_API_URL=http://localhost:3000/portal/api
# Origin lengkap aplikasi, tanpa slash di akhir.
# Sejak sinkronisasi 12 September 2026, kode repository instruktur tidak lagi
# membaca BASE_URL. Variabel ini disimpan supaya versi lama tetap jalan.
BASE_URL=http://IP_EKSTERNAL_VM/portal
# Pengganti BASE_URL untuk kode versi baru. Dipakai satu kali pada rute
# api/katalog-data-3d/create untuk menyusun URL berkas model. Nilainya sama dengan
# BASE_URL. Kalau kosong, URL modelnya menjadi "undefined/api/..." dan model 3D
# gagal dibuka. Dibaca saat runtime, jadi cukup diisi di berkas .env ini.
NEXT_PUBLIC_URL_BASE_PATH=http://IP_EKSTERNAL_VM/portal
# Sebelas variabel berikut hanya diperlukan fitur katalog 2D dan 3D, yaitu fitur
# yang membuat tabel spasial di PostGIS lalu menerbitkannya sebagai layer di
# GeoServer. Halaman publik, GeoServer, dan WFS berjalan tanpa variabel ini.
#
# POSTGIS_* dipakai pustaka pg secara langsung, bukan Prisma. Pada Supabase pakai
# koneksi langsung port 5432, bukan pooler 6543.
POSTGIS_HOST=
POSTGIS_PORT=5432
POSTGIS_DB=postgres
POSTGIS_USER=
POSTGIS_PASSWORD=
# Schema tempat tabel spasial dibuat. Modul 4 memakai schema gis.
POSTGIS_SCHEMA=gis
# GEOSERVER_* dipakai untuk memanggil REST API GeoServer dari dalam container.
GEOSERVER_URL=http://geoserver:8080/geoserver
GEOSERVER_USERNAME=admin
# Harus sama persis dengan GEOSERVER_ADMIN_PASSWORD di atas.
GEOSERVER_PASSWORD=
GEOSERVER_WORKSPACE=geoportal
# Nama datastore PostGIS yang sudah dibuat di GeoServer.
GEOSERVER_POSTGIS_DATASTORE=

.gitignore di root repository fork. Tambahkan satu baris supaya data GeoServer tidak ikut masuk Git:

/geoserver-data/

Dua skrip ini dipakai pada bagian 2 untuk memeriksa berkas konfigurasi sebelum push, dan berguna juga setiap kali berkas itu diubah lagi. Keduanya berjalan dengan Node.js saja.

Terminal laptop
mkdir -p scripts

scripts/check-config.mjs memeriksa docker-compose.yml dan cloudbuild.yaml, dan gagal bila service di berkas compose bukan tiga.

scripts/check-config.mjs
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
// YAML dimuat lewat createRequire karena paket ini berbentuk CommonJS,
// sehingga import bernama tidak tersedia di berkas .mjs.
const YAML = createRequire(import.meta.url)('yaml');
const names = ['docker-compose.yml', 'cloudbuild.yaml'];
let failed = false;
for (const file of names) {
try {
const data = YAML.parse(readFileSync(file, 'utf8'));
console.log('OK ' + file + ' -> ' + Object.keys(data).join(', '));
if (names[0] === file) {
const services = Object.keys(data.services ?? {});
console.log(' service: ' + services.join(', '));
if (services.length !== 3) {
console.error(' HARUS 3 service, ditemukan ' + services.length);
failed = true;
}
}
} catch (error) {
console.error('GAGAL ' + file + ': ' + error.message);
failed = true;
}
}
process.exit(failed ? 1 : 0);

scripts/periksa-nginx.mjs memeriksa struktur nginx.conf. Skrip ini sengaja tidak memakai Docker maupun Podman, supaya bisa dijalankan di sistem operasi apa pun.

Isi scripts/periksa-nginx.mjs

// Pemeriksa berkas konfigurasi Nginx yang berjalan dengan Node.js saja.
// Jalankan: node scripts/periksa-nginx.mjs nginx.conf
import { readFileSync } from 'node:fs';
const berkas = process.argv[2] ?? 'nginx.conf';
const isi = readFileSync(berkas, 'utf8');
// Buang komentar, tetapi hormati tanda kutip supaya tanda # di dalam nilai aman.
function tanpaKomentar(teks) {
let hasil = '';
let kutip = null;
for (let i = 0; i < teks.length; i++) {
const c = teks[i];
if (kutip) {
hasil += c;
if (c === kutip && teks[i - 1] !== '\\') kutip = null;
continue;
}
if (c === '"' || c === "'") { kutip = c; hasil += c; continue; }
if (c === '#') { while (i < teks.length && teks[i] !== '\n') i++; hasil += '\n'; continue; }
hasil += c;
}
return hasil;
}
const bersih = tanpaKomentar(isi);
const masalah = [];
// Menghilangkan titik koma membuat dua baris menyatu, sehingga jumlah titik koma
// pada baris itu menjadi kurang dari jumlah pernyataannya.
bersih.split('\n').forEach((baris, nomor) => {
const t = baris.trim();
if (!t) return;
const kurungBuka = (t.match(/\{/g) ?? []).length;
const kurungTutup = (t.match(/\}/g) ?? []).length;
const titikKoma = (t.match(/;/g) ?? []).length;
if (kurungBuka > 0 && kurungTutup === 0) {
if (!t.replace(/[{};]/g, '').trim()) {
masalah.push(`baris ${nomor + 1}: blok dibuka tanpa nama directive di depannya`);
}
return;
}
if (kurungBuka === 0 && kurungTutup > 0 && titikKoma === 0) return;
if (titikKoma === 0) {
masalah.push(`baris ${nomor + 1}: "${t.slice(0, 50)}" tidak diakhiri titik koma`);
return;
}
if (titikKoma > 1 && kurungBuka === 0) {
masalah.push(`baris ${nomor + 1}: ada ${titikKoma} pernyataan tanpa blok`);
}
});
const totalBuka = (bersih.match(/\{/g) ?? []).length;
const totalTutup = (bersih.match(/\}/g) ?? []).length;
if (totalBuka !== totalTutup) {
masalah.push(`kurung kurawal tidak seimbang, ${totalBuka} buka dan ${totalTutup} tutup`);
}
const proxy = [...bersih.matchAll(/proxy_pass\s+([^;]+);/g)].map((m) => m[1].trim());
const adaResolver = /resolver\s+[^;]+;/.test(bersih);
if (proxy.some((p) => p.startsWith('$')) && !adaResolver) {
masalah.push('proxy_pass memakai variabel tanpa directive resolver, nama upstream tidak akan terselesaikan');
}
if (!/server\s*\{/.test(bersih)) masalah.push('tidak ada blok server');
if (!/listen\s+\d+/.test(bersih)) masalah.push('tidak ada directive listen');
if (proxy.length === 0) masalah.push('tidak ada proxy_pass sama sekali');
console.log(`Berkas : ${berkas}`);
console.log(`proxy_pass : ${proxy.length > 0 ? proxy.join(', ') : '(tidak ada)'}`);
console.log(`resolver : ${adaResolver ? 'ada' : 'tidak ada'}`);
if (masalah.length === 0) {
console.log('HASIL: struktur konfigurasi valid');
process.exit(0);
}
console.log(`HASIL: ${masalah.length} masalah`);
for (const m of masalah) console.log(` - ${m}`);
process.exit(1);

Simpan berkasnya, lalu lanjut ke bagian 2 untuk menjalankan keduanya.

Kesalahan pada berkas konfigurasi baru ketahuan setelah Cloud Build berjalan, dan satu siklus build memakan beberapa menit. Periksa dulu dari laptop supaya kesalahan tertangkap dalam hitungan detik.

Bagian ini memakai Node.js saja, tanpa Docker maupun Podman. Alasannya dua. Asisten memakai sistem operasi dan perkakas yang berbeda-beda, dan memasang container runtime hanya untuk memeriksa berkas terlalu berat. Yang diperiksa di sini juga bukan perilaku container, melainkan kesalahan penulisan, dan itu bisa dikerjakan Node.js.

Pembuktian yang sebenarnya tetap terjadi di VM pada bagian 14 dan seterusnya.

Terminal laptop
node scripts/check-config.mjs

Hasil yang diharapkan:

OK docker-compose.yml -> services, networks
service: nextjs, geoserver, nginx
OK cloudbuild.yaml -> substitutions, steps, images, options

Kalau service yang muncul bukan tiga, berarti ada bagian services yang hilang atau salah indentasi.

Terminal laptop
node scripts/periksa-nginx.mjs nginx.conf

Hasil yang diharapkan:

Berkas : nginx.conf
proxy_pass : $geoserver_upstream, $nextjs_upstream
resolver : ada
HASIL: struktur konfigurasi valid

Skrip ini memeriksa empat hal yang paling sering salah: directive tanpa titik koma, blok yang dibuka tanpa nama directive, kurung kurawal yang tidak seimbang, serta proxy_pass yang memakai variabel tetapi tidak disertai resolver. Yang terakhir itu penting, karena tanpa resolver container nginx_proxy akan keluar dengan kode 1 dan gejalanya hanya Connection refused di browser.

Skrip akan menyebut nomor baris dan masalahnya, misalnya:

HASIL: 1 masalah
- baris 46: "proxy_pass $nextjs_upstream" tidak diakhiri titik koma

Perbaiki berkasnya lalu jalankan ulang sampai keduanya lolos sebelum lanjut ke bagian 3.

Kalau skrip tidak bisa dijalankan, periksa lima hal berikut dengan mata:

  1. docker-compose.yml memuat tiga service: nextjs, geoserver, dan nginx.
  2. nginx.conf memuat blok location /geoserver/ dan location = /geoserver.
  3. Setiap proxy_pass memakai nama service, bukan localhost. Nilai yang benar adalah http://nextjs:3000 dan http://geoserver:8080.
  4. GEOSERVER_ADMIN_PASSWORD sudah diisi di .env dengan openssl rand -hex 16, dan tidak memuat tanda dolar. Nilai ini tidak ditulis di docker-compose.yml.
  5. .gitignore memuat /geoserver-data/.

Setelah keenam berkas siap, commit dan push dari laptop. Push inilah yang nanti memicu Cloud Build pada bagian 17.

Terminal laptop
cd personal-geoportal-peserta
git status --short
git add docker-compose.yml nginx.conf .env.example .gitignore \
scripts/check-config.mjs scripts/periksa-nginx.mjs
git commit -m "feat: tambah GeoServer dan rute nginx"
git push origin main

Jangan pernah memakai git add . tanpa memeriksa git status --short lebih dahulu, supaya berkas .env yang sudah terisi tidak ikut ter-commit.

Login ke Google Cloud Console menggunakan email yang didaftarkan oleh koordinator. Pada kunjungan pertama, Google mungkin meminta persetujuan terhadap persyaratan layanan.

Persetujuan layanan pada akun peserta

Gambar 1. Persetujuan persyaratan layanan muncul sebelum project dapat dibuka.

Pilih project yang diberikan oleh akun master. Pastikan nama project terlihat pada bagian atas Google Cloud Console.

Akun peserta berhasil membuka project pengujian

Gambar 2. Pengujian awal membuktikan bahwa email peserta dapat membuka project melalui IAM. Pada skema kelompok, pilih Project ID yang dikirim akun master.

Buka Cloud Shell menggunakan akun asisten. Ganti PARTICIPANT_ID dengan slug yang sudah disepakati. Gunakan huruf kecil, angka, atau tanda hubung tanpa spasi. Contoh berikut memakai slug nama01.

Cloud Shell asisten
PROJECT_ID="geoportal-kelompok-a-xxxxx"
REGION="asia-southeast2"
ZONE="asia-southeast2-b"
PARTICIPANT_ID="nama01"
REPOSITORY="katalog-images"
APP_DIR="/opt/webgis/app"
VM_NAME="webgis-${PARTICIPANT_ID}"
BUILD_SA_NAME="cb-deployer-${PARTICIPANT_ID}"
CONNECTION_NAME="github-${PARTICIPANT_ID}"
LINKED_REPO_NAME="repo-${PARTICIPANT_ID}"
TRIGGER_NAME="deploy-${PARTICIPANT_ID}"
IMAGE_NAME="nextjs-${PARTICIPANT_ID}"
STATIC_IP_NAME="webgis-ip-${PARTICIPANT_ID}"
DNS_ZONE_NAME="geoportal-${PARTICIPANT_ID}"
BUILD_SA="${BUILD_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
VM_REGION="${ZONE%-*}"
gcloud config set project "$PROJECT_ID"
gcloud auth list
gcloud config get-value project

Dua variabel di atas punya tugas yang berbeda, dan bedanya penting begitu kamu pindah region:

Variabel Artinya Boleh diubah?
REGION Lokasi Artifact Registry bersama katalog-images Tidak. Semua asisten memakai asia-southeast2
ZONE Tempat VM sendiri dibuat Boleh. Isi asia-southeast1-b bila jatah 4 alamat IP publik di asia-southeast2 sudah habis

VM_REGION diturunkan otomatis dari ZONE supaya keduanya tidak pernah bertentangan. Nilai itu dipakai saat mengunci alamat IP statis pada bagian 24, karena alamat IP statis harus berada di region yang sama dengan VM-nya. Kalau keduanya berbeda, perintahnya gagal dengan pesan yang tidak menyebut region sebagai penyebabnya.

Pastikan akun aktif adalah email asisten dan hasil perintah terakhir sama dengan PROJECT_ID. Jangan memakai slug asisten lain. Variabel Cloud Shell tidak tersimpan secara permanen. Jalankan kembali blok ini jika sesi Cloud Shell terputus atau terminal baru dibuka.

API sudah diaktifkan oleh akun master. Asisten cukup memeriksa layanan utama:

Cloud Shell asisten
gcloud services list --enabled \
--filter="config.name:(compute.googleapis.com OR cloudbuild.googleapis.com OR artifactregistry.googleapis.com OR iap.googleapis.com OR dns.googleapis.com OR domains.googleapis.com OR secretmanager.googleapis.com)" \
--format="value(config.name)"

Hubungi akun master jika satu layanan belum terlihat. Asisten memang memiliki izin untuk mengaktifkan layanan yang terlewat, tetapi perubahan API berlaku untuk seluruh project kelompok.

Asisten dapat membuat Service Account sendiri karena akun master sudah memberikan role IAM yang diperlukan.

Cloud Shell asisten
gcloud iam service-accounts create "$BUILD_SA_NAME" \
--display-name="Cloud Build Deployer"

Jika perintah menampilkan bahwa Service Account sudah ada, lanjutkan ke pemberian role.

Cloud Shell asisten
for ROLE in \
roles/cloudbuild.builds.builder \
roles/artifactregistry.writer \
roles/compute.instanceAdmin.v1 \
roles/compute.osAdminLogin \
roles/iap.tunnelResourceAccessor \
roles/logging.logWriter
do
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$BUILD_SA" \
--role="$ROLE" \
--condition=None
done

Jika gcloud meminta kondisi karena policy telah memiliki binding bersyarat, gunakan --condition=None seperti pada contoh. Jangan memilih kondisi sementara milik koneksi Cloud Build.

Cloud Shell asisten
gcloud artifacts repositories describe "$REPOSITORY" \
--location="$REGION"

Jangan membuat repository katalog-images kembali. Semua asisten memakai repository yang sama, tetapi nama image wajib berbeda melalui variabel IMAGE_NAME.

VM memakai Compute Engine default Service Account. Service Account ini memerlukan akses untuk menarik image dari Artifact Registry. Service Account Cloud Build juga harus boleh bertindak pada identitas VM saat membuka SSH berbasis OS Login.

Cloud Shell asisten
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" \
--format='value(projectNumber)')"
VM_SA="${PROJECT_NUMBER}-asisten01@example.com"
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$VM_SA" \
--role="roles/artifactregistry.reader" \
--condition=None
gcloud iam service-accounts add-iam-policy-binding "$VM_SA" \
--member="serviceAccount:$BUILD_SA" \
--role="roles/iam.serviceAccountUser" \
--condition=None

Binding kedua sengaja diberikan pada VM_SA, bukan hanya pada project. Google merekomendasikan pemberian roles/iam.serviceAccountUser pada Service Account tertentu saat identitas tersebut dipakai oleh Compute Engine. Lihat dokumentasi IAM Compute Engine.

Konfigurasi yang telah diuji memakai e2-medium, Ubuntu 22.04 LTS, disk balanced 30 GB, dan OS Login.

Cloud Shell asisten
gcloud compute instances create "$VM_NAME" \
--zone="$ZONE" \
--machine-type="e2-medium" \
--image-family="ubuntu-2204-lts" \
--image-project="ubuntu-os-cloud" \
--boot-disk-size="30GB" \
--boot-disk-type="pd-balanced" \
--service-account="$VM_SA" \
--scopes="https://www.googleapis.com/auth/cloud-platform" \
--tags="webgis-http,webgis-iap-ssh" \
--metadata="enable-oslogin=TRUE"

Jika VM sudah ada, jangan menjalankan perintah pembuatan kembali. Periksa statusnya:

Cloud Shell asisten
gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format="table(name,status,machineType.basename(),networkInterfaces[0].accessConfigs[0].natIP)"

Kerjakan langkah ini segera setelah VM dibuat, sebelum melangkah lebih jauh. Alamat IP sementara yang diberikan Compute Engine berubah setiap kali VM dimatikan dan dinyalakan lagi, dan perubahan itu mematahkan tiga hal sekaligus: penanda halaman di browser, record DNS bila kamu memakai domain, dan NEXTAUTH_URL pada .env.

Promosikan alamat yang sedang dipakai menjadi alamat statis:

Cloud Shell asisten
EXTERNAL_IP="$(gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)')"
gcloud compute addresses create "$STATIC_IP_NAME" \
--region="$VM_REGION" \
--addresses="$EXTERNAL_IP"
gcloud compute addresses describe "$STATIC_IP_NAME" \
--region="$VM_REGION" \
--format="table(name,address,status)"

Perhatikan $VM_REGION, bukan $REGION. Alamat IP statis harus berada di region yang sama dengan VM-nya, sedangkan REGION menunjuk lokasi Artifact Registry bersama.

Nama alamat statis memakai slug asisten. Jangan memakai nama milik asisten lain.

Kalau alamat dengan nama itu sudah pernah dibuat, perintah create akan gagal dengan pesan already exists. Itu bukan masalah, karena tujuannya sudah tercapai. Periksa hasilnya dengan gcloud compute addresses describe, lalu pastikan statusnya IN_USE dan alamatnya sama dengan alamat VM sendiri. Kalau berbeda, berarti alamat itu milik resource lain dan namanya perlu diganti.

Angka berikut diukur pada 12 dan 13 September 2026 di project kelompok, dengan empat VM berjalan. Sebagian kuota berlaku per project per region, sebagian lagi per project tanpa terikat region, dan perbedaannya penting: kuota yang per project tidak dapat ditambah dengan membuat project baru di region lain.

Nilainya bisa kembali ke bawaan bila billing project terganggu atau billing account diganti.

Kuota Cakupan Batas Artinya untuk pelatihan
In-use regional external IPv4 addresses project per region 4 4 VM ber-IP publik dalam satu region
VM instances project per region 8 8 VM dalam satu region
Static regional external IPv4 addresses project per region 8 8 IP statis dalam satu region
SSD_TOTAL_GB untuk disk balanced project per region 250 GB 8 VM berdisk 30 GB
DISKS_TOTAL_GB untuk disk standard project per region 2048 GB 68 VM berdisk 30 GB
CPUs project per region 32 jauh di atas kebutuhan, bukan penghalang
CPUs (all regions) project 12 batas seluruh VM dalam satu project, lintas region
E2_CPUS project per region 8 batas khusus keluarga mesin E2

Empat hal yang perlu dipahami dari tabel itu:

  1. Yang paling dulu mengikat adalah alamat IP publik. Batasnya 4 per project per region, dan itu terpakai penuh oleh empat VM. Menambah VM kelima di region yang sama ditolak dengan pesan berikut, dan penolakan ini sudah dibuktikan pada 13 September 2026:

    Quota 'IN_USE_ADDRESSES' exceeded. Limit: 4.0 in region asia-southeast2.
    metric name = compute.googleapis.com/regional_in_use_addresses
    limit name = IN-USE-ADDRESSES-per-project-region
    limit = 4.0
    dimensions = region: asia-southeast2

    Perhatikan baris limit name itu, karena namanya sendiri menyatakan cakupan kuotanya, yaitu per project per region. Pesan yang sama juga menyarankan memindahkan permintaan ke zone lain. Saran itu tidak berlaku di sini: kuota ini dihitung per region, jadi berpindah zone di region yang sama tidak menambah jatah satu alamat pun.

  2. Kuota dihitung per project, bukan per akun. Tiga project berbeda di region yang sama masing-masing mendapat jatah penuh, jadi tiga project memberi 12 alamat IP publik. Inilah sebabnya struktur pelatihan memakai satu project untuk empat peserta, bukan satu project untuk dua belas peserta.

  3. Kuota dihitung per region, bukan per zone. Membagi VM ke asia-southeast2-a, -b, dan -c tidak menambah jatah satu pun. Yang menambah jatah hanya pindah region.

  4. Jenis disk menentukan kuota mana yang terpakai. pd-balanced masuk ke SSD_TOTAL_GB yang hanya 250 GB, sedangkan pd-standard masuk ke DISKS_TOTAL_GB yang 2048 GB. Panduan ini memakai pd-balanced karena lebih cepat, dan pada pengukuran 13 September 2026 empat VM berdisk 30 GB memakai 120 GB dari 250 GB yang tersedia.

Diuji pada 13 September 2026 memakai satu akun master dan tiga project di region yang sama, asia-southeast2. Sebuah VM direkam menjadi machine image, lalu delapan VM dibuat dari rekaman itu, empat di project B dan empat di project C, sementara project A tetap memakai empat VM yang sudah ada. Keduabelas VM berstatus RUNNING pada saat bersamaan, masing-masing dengan alamat IP publiknya sendiri.

Yang dibuktikan uji ini ada tiga. Pertama, kuota regional memang dihitung per project, bukan per akun, sehingga tiga project di region yang sama tidak saling mengurangi. Kedua, batas praktisnya adalah empat VM ber-IP publik per project per region, dan itu ditegaskan oleh pesan penolakan di atas. Ketiga, batas itu tidak dapat dilewati dengan berpindah zone.

Beban CPU tiga belas VM berjalan bersamaan

Section titled “Beban CPU tiga belas VM berjalan bersamaan”

Diukur dari Cloud Shell memakai metrik compute.googleapis.com/instance/cpu/utilization, jendela satu jam dengan rata-rata per lima menit. Angka di bawah adalah pemakaian terhadap satu vCPU, sehingga nilai di atas 100 persen berarti VM sesaat memakai lebih dari satu vCPU.

Kelompok Jumlah VM Umur saat diukur CPU rata-rata per VM
VM tenang 4 14 sampai 46 jam 2,74 persen
VM baru menyala 8 sekitar 35 menit 14,00 persen

VM yang baru menyala memakai CPU sekitar lima kali lebih tinggi daripada VM yang sudah tenang. Penyebabnya fase menyalakan container, bukan isi datanya. Yang membuktikan itu: VM di project A justru yang berisi shapefile terunggul, tetapi pemakaian CPU-nya paling rendah.

Beban agregat seluruh VM pada saat pengukuran:

Project Jumlah VM Rata-rata Puncak
A 5 0,12 vCPU 0,74 vCPU
B 4 0,42 vCPU 2,51 vCPU
C 4 0,71 vCPU 3,80 vCPU
Total 13 1,25 vCPU 7,05 vCPU

Bandingkan dengan kapasitas 32 vCPU per region dan 12 vCPU per project. Empat VM yang menyala bersamaan meminta sekitar 3,8 vCPU, sehingga satu project masih sanggup menyalakan keempat VM pesertanya secara serentak tanpa menyentuh batas CPU.

Mengukur pemakaian CPU seluruh VM dari Cloud Shell

Section titled “Mengukur pemakaian CPU seluruh VM dari Cloud Shell”

Perintah gcloud tidak punya cara membaca deret waktu metrik, jadi pengukurannya lewat REST API Cloud Monitoring dengan token dari gcloud. Jalankan dari Cloud Shell, bukan dari dalam VM.

Cloud Shell asisten
PROJECTS="geoportal-kelompok-a-xxxxx geoportal-kelompok-b-xxxxx geoportal-kelompok-c-xxxxx"
TOKEN="$(gcloud auth print-access-token)"
END="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
START="$(date -u -v-60M +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '60 minutes ago' +%Y-%m-%dT%H:%M:%SZ)"
for P in $PROJECTS; do
curl -sS -H "Authorization: Bearer $TOKEN" \
"https://monitoring.googleapis.com/v3/projects/$P/timeSeries?filter=metric.type%3D%22compute.googleapis.com%2Finstance%2Fcpu%2Futilization%22&interval.startTime=$START&interval.endTime=$END&aggregation.alignmentPeriod=300s&aggregation.perSeriesAligner=ALIGN_MEAN" \
-o "/tmp/cpu-$P.json"
echo "$P: $(wc -c < /tmp/cpu-$P.json) byte"
done

Lalu olah berkasnya. Nama VM ada di metric.labels.instance_name, bukan di resource.labels, dan nilainya berupa pecahan nol sampai satu sehingga perlu dikali 100 untuk menjadi persen.

Cloud Shell asisten
python3 - <<'PY'
import json, os
PROJECTS = ['geoportal-kelompok-a-xxxxx', 'geoportal-kelompok-b-xxxxx',
'geoportal-kelompok-c-xxxxx']
SHORT = {p: p.split('-')[-2].upper() for p in PROJECTS}
VCPU = 2 # e2-medium
semua = []
for p in PROJECTS:
path = f'/tmp/cpu-{p}.json'
if not os.path.exists(path):
print(f" {p}: berkas tidak ada"); continue
d = json.load(open(path))
if 'error' in d:
print(f" {p}: {d['error'].get('message','?')[:80]}"); continue
for ts in d.get('timeSeries', []):
nama = ts.get('metric', {}).get('labels', {}).get('instance_name', '?')
vals = [float(pt['value']['doubleValue']) for pt in ts.get('points', [])
if 'doubleValue' in pt['value']]
if vals:
semua.append({'project': SHORT[p], 'vm': nama,
'rata': sum(vals)/len(vals)*100, 'puncak': max(vals)*100,
'titik': len(vals)})
semua.sort(key=lambda r: -r['rata'])
print(f"\n{'PROJ':<4} {'VM':<20} {'RATA2':>8} {'PUNCAK':>8} {'SMP':>5}")
print("-" * 52)
for r in semua:
print(f"{r['project']:<4} {r['vm']:<20} {r['rata']:>7.2f}% {r['puncak']:>7.2f}% {r['titik']:>5}")
print("-" * 52)
tot = sum(r['rata'] for r in semua)
print(f" {len(semua)} VM{'':<15} {tot:>7.2f}% setara {tot/100:.2f} vCPU")
PY

Umur VM diambil dari metadata instance, bukan dari metrik uptime. Metrik itu bersifat kumulatif, sehingga jika dirata-ratakan hasilnya menyesatkan:

Cloud Shell asisten
for P in $PROJECTS; do
gcloud compute instances list --project="$P" \
--format="csv[no-heading](name,creationTimestamp)" | sed "s|^|$P,|"
done

Kenaikan kuota belum bisa diajukan sekarang. Formulir permohonannya menolak nilai di atas batas yang berlaku dengan alasan riwayat pemakaian belum mencukupi. Billing account ini sudah diaktifkan sebagai akun berbayar, jadi penolakan itu bukan karena jenis akun melainkan karena projectnya masih baru. Coba ajukan lagi setelah project punya riwayat pemakaian beberapa hari. Sementara itu, rancang di dalam angka tabel di atas.

asia-southeast1 sudah diperiksa dan batasnya identik dengan asia-southeast2: 4 alamat IP publik, 8 VM, 8 IP statis, dan 250 GB disk balanced. Jadi memindahkan sebagian VM ke sana benar-benar menambah jatah, sedangkan memindahkan VM ke zone lain di region yang sama tidak menambah apa pun.

VM yang dimatikan tetap memakai slot INSTANCES dan tetap memakai disknya, tetapi melepas alamat IP publik dan berhenti memakai kuota CPU. Ini terlihat pada bacaan kuota 12 September 2026: tiga VM terdaftar, tetapi hanya dua alamat IP yang terpakai dan hanya dua unit CPU yang tercatat.

Akibat praktisnya, alamat IP publik bisa dipakai bergiliran selama asisten tidak menguji bersamaan. Ini tidak berlaku untuk hari pelatihan, karena saat itu semua VM harus hidup pada waktu yang sama.

Alamat IP yang dilepas saat VM dimatikan adalah alamat sementara. Untuk VM yang dihubungkan ke domain, pakai alamat IP statis supaya record A pada DNS tidak perlu diperbarui setiap kali VM dinyalakan.

Domain menuntut alamat IP publik, karena record A pada DNS hanya bisa menunjuk ke alamat IP. Terowongan IAP tidak bisa menggantikannya untuk keperluan ini: terowongan hanya hidup selama perintahnya berjalan di komputer seseorang, sedangkan domain harus tetap terbuka kapan saja bagi siapa saja.

Karena itu urutan pilihannya begini:

  1. Pindah region. Jatah 4 alamat IP publik berlaku per region, jadi VM kelima dan seterusnya ditaruh di region lain, misalnya asia-southeast1. Ini satu-satunya cara menambah VM berdomain tanpa mengubah arsitektur.
  2. Pakai terowongan IAP hanya untuk VM yang memang tidak perlu domain, misalnya VM tambahan untuk mengukur beban lima VM serentak.

Pindah region tidak menuntut penyesuaian lain. Variabel ZONE sudah per asisten, firewall allow-webgis-http berlaku di seluruh region karena memakai network tag, dan Cloud Domains maupun Cloud DNS tidak terikat region.

Kalau VM itu tidak perlu domain dan keempat slot alamat IPv4 eksternal sudah terpakai, VM berikutnya tetap bisa dibuat tanpa alamat IP publik dan diakses lewat terowongan IAP. Perbedaannya hanya satu tanda:

Cloud Shell asisten
gcloud compute instances create "$VM_NAME" \
--zone="$ZONE" \
--machine-type="e2-medium" \
--image-family="ubuntu-2204-lts" \
--image-project="ubuntu-os-cloud" \
--boot-disk-size="30GB" \
--boot-disk-type="pd-balanced" \
--no-address \
--service-account="$VM_SA" \
--scopes="https://www.googleapis.com/auth/cloud-platform" \
--tags="webgis-http,webgis-iap-ssh" \
--metadata="enable-oslogin=TRUE"

Terowongan IAP sudah terbukti bekerja pada 12 September 2026: /portal membalas 200 dan /geoserver/web membalas 302 lewat terowongan, sama seperti lewat alamat IP publik.

Untuk memeriksa tanpa browser, jalankan terowongan di satu tab Cloud Shell lalu periksa dari tab kedua:

Cloud Shell asisten, tab kedua
curl -sS -o /dev/null -w "portal %{http_code}\n" http://localhost:8080/portal
curl -sS -o /dev/null -w "geoserver %{http_code}\n" http://localhost:8080/geoserver/web

Firewall dibuat satu kali oleh akun master. Asisten hanya perlu memeriksa dua rule:

Cloud Shell asisten
gcloud compute firewall-rules list \
--filter="name:(allow-webgis-http OR allow-webgis-iap-ssh)"

VM dan firewall berhasil dibuat

Gambar 3. VM berjalan dan dua rule firewall tersedia untuk dipakai bersama.

Masuk ke VM melalui IAP:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap

Jalankan perintah berikut di dalam VM untuk memasang Docker Engine, Docker Compose, Git, dan Google Cloud CLI.

Terminal VM
sudo apt-get update
sudo apt-get install -y ca-certificates curl git gnupg
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | \
sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list > /dev/null
sudo apt-get update
sudo apt-get install -y google-cloud-cli

Panduan instalasi Docker mengikuti dokumentasi resmi Docker untuk Ubuntu.

Pastikan dua perangkat utama sudah terpasang sebelum menarik repository. Perintah docker compose version harus menampilkan versi plugin Compose, bukan pesan bahwa subperintah tidak dikenal.

Terminal VM
sudo docker compose version
sudo docker run --rm hello-world
gcloud --version

Jika docker run menolak dengan pesan izin, kelompok docker belum aktif untuk pengguna saat ini. Tambahkan pengguna ke kelompok tersebut, keluar dari sesi SSH, lalu masuk kembali.

Terminal VM
sudo usermod -aG docker "$USER"
exit

Cloud Build menjalankan Docker sebagai root, jadi izin pengguna biasa tidak menghambat deployment, tetapi perintah manual pada VM akan gagal tanpa perbaikan ini.

Siapkan juga folder aplikasi sebelum repository di-clone pada bagian berikutnya.

Terminal VM
sudo mkdir -p /opt/webgis
sudo chown "$USER:$USER" /opt/webgis
ls -ld /opt/webgis

Container nextjs belum bisa dijalankan sebelum Cloud Build mengisi NEXTJS_IMAGE. Karena itu urutan yang benar adalah clone, isi .env, hubungkan repository, buat trigger, baru push. Setelah push, Cloud Build menarik image dan menyalakan container nextjs.

Repository instruktur bersifat public, jadi clone cukup memakai HTTPS tanpa token. Ganti GITHUB_USERNAME dengan username GitHub asisten.

Terminal VM
GITHUB_USERNAME="ISI_USERNAME_GITHUB"
GITHUB_REPOSITORY="https://github.com/${GITHUB_USERNAME}/personal-geoportal-peserta.git"
git clone "$GITHUB_REPOSITORY" /opt/webgis/app
cd /opt/webgis/app
cp .env.example .env

Periksa pemilik direktori aplikasi sebelum melanjutkan:

Terminal VM
ls -ld /opt/webgis/app

Hasilnya harus menampilkan nama pengguna VM, bukan root. Kalau dimiliki root, semua perintah berikutnya yang menulis ke direktori itu akan gagal dengan Permission denied. Gejala pertamanya biasanya muncul sebagai fatal: detected dubious ownership in repository, karena git menolak bekerja di repository yang pemiliknya berbeda dari pengguna yang menjalankannya. Penyebabnya adalah langkah sudo chown "$USER:$USER" /opt/webgis pada bagian 12 terlewat, atau clone dijalankan memakai sudo. Perbaiki dengan:

Terminal VM
sudo chown -R "$USER:$USER" /opt/webgis/app
ls -ld /opt/webgis/app

Menambahkan safe.directory lewat git config tidak menyelesaikan masalah ini. Perintah itu hanya membungkam peringatannya, sedangkan izin tulisnya tetap tidak ada sehingga git pull berikutnya gagal lagi.

Jika nama fork mengikuti nama bawaan personal-geoportal, sesuaikan URL-nya:

Terminal VM
GITHUB_REPOSITORY="https://github.com/${GITHUB_USERNAME}/personal-geoportal.git"

Periksa remote dan commit yang dipakai:

Terminal VM
git remote -v
git log --oneline -1

Commit dan push dikerjakan dari laptop yang sudah login GitHub, karena git push selalu butuh kredensial walaupun repository public. VM hanya perlu membaca repository, jadi tidak ada token yang perlu disimpan di VM.

Kalau fork sudah pernah di-clone ke VM ini lalu berkas konfigurasinya berubah, tarik perubahannya sebelum menyalakan container. Tanpa langkah ini, VM masih memakai berkas lama dan gejalanya membingungkan karena berkas di GitHub sudah benar.

Terminal VM
cd /opt/webgis/app
git status --short
git pull --ff-only
git log --oneline -1

git status --short harus kosong sebelum git pull. Kalau ada perubahan lokal yang belum disimpan, simpan atau batalkan dulu, karena --ff-only akan menolak menarik perubahan di atas berkas yang sudah diubah.

Ambil alamat IP eksternal VM dari Cloud Shell lain, atau catat nilai EXTERNAL_IP yang muncul saat VM dibuat.

Cloud Shell asisten
gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)'

Buka .env pada VM:

Terminal VM
nano /opt/webgis/app/.env

Buat dulu tiga nilai acak. Masing-masing harus berbeda:

Terminal VM
openssl rand -hex 32 # untuk JWT_SECRET
openssl rand -hex 32 # untuk NEXTAUTH_SECRET
openssl rand -hex 16 # untuk GEOSERVER_ADMIN_PASSWORD

Buka .env pada VM, lalu isi dengan pola berikut. Ganti IP_EKSTERNAL_VM dengan alamat IP yang muncul di atas, dan ganti ketiga nilai acak dengan hasil perintah sebelumnya. Jangan memasukkan berkas .env yang sudah terisi ke Git.

# Nilai sementara. Cloud Build menimpanya dengan tag image setelah build pertama.
NEXTJS_IMAGE=nginx:1.27-alpine
DATABASE_URL=
JWT_SECRET=ISI_SECRET_ACAK
JWT_EXPIRES_IN=1h
NEXTAUTH_SECRET=ISI_SECRET_ACAK_BERBEDA
NEXTAUTH_URL=http://IP_EKSTERNAL_VM/portal
AUTH_API_URL=http://localhost:3000/portal/api
GEOSERVER_ADMIN_PASSWORD=ISI_KATA_SANDI_GEOSERVER
BASE_URL=http://IP_EKSTERNAL_VM/portal
NEXT_PUBLIC_URL_BASE_PATH=http://IP_EKSTERNAL_VM/portal
POSTGIS_HOST=ISI_HOST_POSTGIS
POSTGIS_PORT=5432
POSTGIS_DB=postgres
POSTGIS_USER=ISI_USER_POSTGIS
POSTGIS_PASSWORD=ISI_KATA_SANDI_POSTGIS
POSTGIS_SCHEMA=gis
GEOSERVER_URL=http://geoserver:8080/geoserver
GEOSERVER_USERNAME=admin
GEOSERVER_PASSWORD=ISI_KATA_SANDI_GEOSERVER
GEOSERVER_WORKSPACE=geoportal
GEOSERVER_POSTGIS_DATASTORE=ISI_NAMA_DATASTORE

BASE_URL dan NEXTAUTH_URL memakai nilai yang sama, keduanya tanpa slash di akhir. Ini berbeda dari versi panduan sebelumnya yang mewajibkan slash pada NEXTAUTH_URL, karena aplikasi tidak lagi memakai trailingSlash. Keduanya tanpa slash karena kode menambahkan sendiri path API setelahnya.

BASE_URL tidak lagi dibaca kode setelah sinkronisasi 12 September 2026, karena pemanggilan API dari komponen klien kini memakai alamat relatif. Penggantinya adalah NEXT_PUBLIC_URL_BASE_PATH, yang dipakai satu kali untuk menyusun URL berkas model pada rute api/katalog-data-3d/create. Kalau nilainya kosong, ketiga panggilan itu menjadi relatif dan gagal dengan 404. Fitur ini juga memerlukan DATABASE_URL yang valid, jadi pengujiannya baru bisa dilakukan setelah database diisi.

Untuk menguji build, Nginx, dan halaman publik, DATABASE_URL boleh kosong. Login, Prisma, dan fitur katalog memerlukan database yang valid.

Dua container ini tidak menunggu Cloud Build, jadi bisa dinyalakan sekarang untuk memastikan berkas konfigurasi sudah benar. Langkah ini juga memicu pembuatan data directory GeoServer pertama kali, jadi container geoserver_app terasa lambat pada percobaan pertama.

Terminal VM
cd /opt/webgis/app
sudo docker compose up -d --no-deps geoserver nginx
sudo docker compose ps

Tanda --no-deps penting. Tanpa itu, compose ikut menyalakan service yang menjadi dependensi nginx, termasuk nextjs yang image-nya belum dibangun. Dengan tanda itu, hanya dua service yang disebut yang dijalankan.

Hasil yang benar pada tahap ini hanya menampilkan dua container: geoserver_app dan nginx_proxy. Container nextjs_portal memang belum ada, karena baru dibuat oleh Cloud Build pada bagian 17.

Kolom PORTS pada geoserver_app menampilkan 8080/tcp tanpa 0.0.0.0 di depannya. Itu artinya port tersebut hanya terbuka di dalam jaringan container, dan memang disengaja. Akses dari luar melewati Nginx pada port 80, dengan alamat /geoserver/web. Jadi jangan menguji GeoServer lewat http://IP_VM:8080, karena koneksinya memang akan ditolak.

docker compose up untuk nextjs belum bisa dijalankan karena NEXTJS_IMAGE masih kosong. Biarkan container nextjs dibuat oleh Cloud Build.

Periksa instalasi, lalu keluar dari VM:

Terminal VM
sudo docker compose version
gcloud --version
exit

Lakukan langkah berikut pada Google Cloud Console:

  1. Buka Cloud Build, lalu pilih Repositories.
  2. Pilih Connect repository.
  3. Isi nama connection sesuai CONNECTION_NAME, misalnya github-nama01.
  4. Pilih GitHub dan login memakai akun pemilik fork.
  5. Izinkan Cloud Build GitHub App hanya untuk repository pelatihan jika memungkinkan.
  6. Pilih repository fork asisten.
  7. Isi nama linked repository sesuai LINKED_REPO_NAME, misalnya repo-nama01.
  8. Selesaikan koneksi dan pastikan statusnya COMPLETE.

Jika halaman meminta Secret Manager API, kembali ke bagian 6 dan pastikan secretmanager.googleapis.com sudah aktif.

Satu project dapat memiliki beberapa GitHub connection. Jangan menghapus atau mengubah connection asisten lain.

Buat trigger dengan konfigurasi berikut:

Pengaturan Nilai
Event Push to a branch
Repository Fork milik asisten
Branch ^main$
Configuration Cloud Build configuration file
File cloudbuild.yaml
Trigger name Nilai TRIGGER_NAME, misalnya deploy-nama01
Service Account Nilai BUILD_SA, misalnya cb-deployer-nama01@PROJECT_ID.iam.gserviceaccount.com

Tambahkan empat substitution variable berikut. Keempatnya wajib diisi. Berkas cloudbuild.yaml pada fork tidak lagi menyediakan nilai bawaan untuk variabel ini, jadi build akan berhenti dengan pesan yang menyebut nama variabel yang kosong. Kegagalan itu disengaja: tanpa nilai unik, image akan memakai satu nama bersama dan menimpa image milik asisten lain di Artifact Registry.

_VM_NAME = webgis-nama01
_VM_ZONE = asia-southeast2-b
_VM_APP_DIR = /opt/webgis/app
_IMAGE_NAME = nextjs-nama01

Ganti suffix nama01 sesuai PARTICIPANT_ID. Nilai _VM_NAME, _IMAGE_NAME, dan Service Account harus menunjuk ke resource milik asisten yang sama. Simpan trigger, lalu pilih Run. Push berikutnya ke branch main akan menjalankan build, push image unik, login ke VM asisten melalui IAP, mengisi NEXTJS_IMAGE, menarik image, dan menjalankan Docker Compose.

Commit dilakukan dari laptop, bukan dari VM. Push inilah yang memicu trigger dan menjalankan seluruh step Cloud Build.

Terminal laptop
cd personal-geoportal-peserta
git status --short
git add docker-compose.yml nginx.conf cloudbuild.yaml .gitignore
git commit -m "feat: tambah GeoServer dan rute nginx"
git push origin main

Pastikan berkas .env yang sudah terisi tidak ikut ter-commit. Periksa dengan git status --short sebelum git add, dan jangan pernah memakai git add . tanpa memeriksa daftar berkas terlebih dahulu.

Build yang berhasil menampilkan seluruh step dengan status hijau.

Seluruh step Cloud Build berhasil

Gambar 4. Build, push, dan deployment SSH selesai tanpa error.

Periksa container dari Cloud Shell. Ada tiga container yang harus terlihat: nextjs_portal, geoserver_app, dan nginx_proxy.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && sudo docker compose ps'

Container Next.js dan Nginx berjalan

Gambar 5. Tangkapan dari pengujian 10 September 2026 ini baru menampilkan nextjs_portal dan nginx_proxy. Setelah GeoServer berjalan pada 11 September 2026, daftar container bertambah satu sehingga menjadi tiga.

Cloud Build hanya menarik image nextjs. Container geoserver_app dan nginx_proxy sudah berjalan sejak bagian 14 dan tidak ikut dibangun ulang. Jika Cloud Build hijau tetapi geoserver_app hilang, berarti docker-compose.yml di VM masih versi dua container dan perlu disinkronkan seperti pada bagian 28.

GeoServer berjalan di belakang Nginx pada port 8080 di dalam jaringan container, jadi port tersebut tidak dibuka ke internet. Akses masuk melalui Nginx pada alamat berikut:

Cloud Shell asisten
EXTERNAL_IP="$(gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)')"
echo "http://${EXTERNAL_IP}/geoserver/web"
curl -sSIL --max-redirs 3 "http://${EXTERNAL_IP}/geoserver/web"

Perhatikan alamat yang dipakai adalah /geoserver/web tanpa slash di akhir. GeoServer mengalihkan /geoserver/web/ ke bentuk tanpa slash, jadi kalau dibalik, kamu hanya melihat satu lompatan tambahan yang tidak perlu.

Login memakai nilai GEOSERVER_ADMIN_USER dan GEOSERVER_ADMIN_PASSWORD pada docker-compose.yml, bukan admin dan geoserver bawaan image.

Container GeoServer memerlukan waktu boot paling lama di antara tiga container. Periksa log sampai muncul penanda selesai:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='sudo docker compose -f /opt/webgis/app/docker-compose.yml logs --tail=40 geoserver'

Jika halaman tidak terbuka, periksa tiga hal berurutan:

  1. docker compose ps menampilkan geoserver_app dengan status running.
  2. sudo docker compose exec geoserver curl -sS http://localhost:8080/geoserver/web menghasilkan HTML. Port 8080 sengaja tidak dipublikasikan ke VM, jadi perintah ini harus dijalankan di dalam container, bukan langsung dari shell VM.
  3. Blok location /geoserver/ pada nginx.conf sudah ikut ter-push ke VM. Berkas Nginx berasal dari clone repository, bukan dari image, jadi perubahan nginx.conf perlu sinkronisasi seperti pada bagian 28.

Bagian ini adalah inti pengujian. Tujuannya membuktikan bahwa GeoServer di VM benar-benar menerima data spasial baru, bukan hanya bisa dibuka. Gunakan berkas contoh batas_admin.geojson atau jalan.geojson dari folder public/data/ pada repository fork.

  1. Buka Data, lalu Workspaces.
  2. Pilih Add new workspace.
  3. Isi Name dengan geoportal dan Namespace URI dengan http://IP_EKSTERNAL_VM/geoportal.
  4. Pilih Save.

GeoServer tidak menyediakan store GeoJSON pada instalasi bawaannya. Format itu adalah ekstensi terpisah, dan image kartoza/geoserver tidak memasangnya. Jadi berkas GeoJSON tidak bisa diunggah langsung.

Data diubah lebih dahulu menjadi shapefile, karena store Shapefile tersedia tanpa konfigurasi tambahan dan geometrinya dikenali langsung dari header berkas. Repository fork sudah memuat hasil konversinya, jadi langkah ini tidak perlu diulang.

Terminal VM
cd /opt/webgis/app
git pull
sudo docker compose cp public/data/batas_admin.zip geoserver:/opt/geoserver/data_dir/
sudo docker compose exec -T geoserver sh -c \
"cd /opt/geoserver/data_dir && unzip -o batas_admin.zip -d batas_admin"
sudo docker compose exec -T geoserver ls -la /opt/geoserver/data_dir/batas_admin/

Perintah terakhir harus menampilkan empat berkas: batas_admin.shp, .shx, .dbf, dan .prj. GeoServer tidak dapat membaca shapefile dari dalam zip, jadi ekstraksi ini wajib.

  1. Buka Data, lalu Stores.
  2. Pilih Add new store.
  3. Pilih Shapefile pada daftar Vector Data Sources.
  4. Pilih workspace geoportal.
  5. Isi Data Source Name dengan batas_admin.
  6. Pada Connection Parameters, tekan Browse, buka folder batas_admin, lalu pilih batas_admin.shp.
  7. Isi Charset dengan UTF-8.
  8. Pilih Save.

Nama Data Source yang dipakai adalah batas_admin, sama dengan nama berkasnya. GeoServer memakai nama itu juga sebagai nama layer, sehingga layer akhirnya menjadi geoportal:batas_admin. Memakai nama berbeda untuk store dan layer hanya menambah kebingungan.

Setelah Save, GeoServer menampilkan halaman New Layer dengan batas_admin terdeteksi.

  1. Pilih Publish.
  2. Pada tab Data, periksa bagian Feature Type Details. Salah satu kolom harus bertipe Geometry. Kalau semua kolom bertipe String, berarti store tidak menemukan geometrinya.
  3. Tekan Compute from data, lalu Compute from native bounds.
  4. Periksa bahwa Native Bounding Box sudah terisi angka, bukan 0 dan -1.
  5. Pilih Save.

Layer baru hanya bisa dilihat pengguna yang login. Sebelum izin diberikan, layer bahkan tidak muncul pada GetCapabilities, sehingga permintaan WFS membalas InvalidParameterValue dengan locator="typeName" seolah-olah layernya tidak ada. Pesan itu menyesatkan, karena layernya sudah ada tetapi belum diumumkan ke publik.

  1. Buka Security, lalu Layers.
  2. Cari geoportal:batas_admin.
  3. Pilih ROLE_ANONYMOUS, lalu aktifkan Read.
  4. Pilih Save.

Jika langkah ini terlewat, permintaan WMS akan menampilkan halaman login, bukan gambar peta, dan permintaan WFS akan gagal dengan pesan layer tidak ditemukan.

Periksa kapabilitas dan geometri melalui endpoint WFS. Nama layer mengikuti nama Data Source, jadi pada contoh ini geoportal:batas_admin.

Cloud Shell asisten
EXTERNAL_IP="$(gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)')"
NAMA_LAYER="geoportal:batas_admin"
curl -sS "http://${EXTERNAL_IP}/geoserver/wfs?service=WFS&version=2.0.0&request=GetFeature&typeNames=${NAMA_LAYER}&outputFormat=application/json&count=1"

Data dinyatakan berhasil diunggah jika perintah tersebut mengembalikan JSON dengan isi features, seperti ini:

{"type":"FeatureCollection","features":[{"type":"Feature","id":"batas_admin.1","geometry":{"type":"MultiPolygon","coordinates":...

Jika jawabannya ServiceException dengan locator="typeName", periksa dua hal berurutan. Pertama, pastikan nama workspace dan nama layer sudah benar pada Data → Layers. Kedua, pastikan izin Read untuk ROLE_ANONYMOUS sudah diberikan, karena tanpa izin itu layer tidak muncul pada GetCapabilities dan seolah-olah tidak ada.

Data yang sudah diunggah tersimpan pada volume ./geoserver-data di VM, sehingga bertahan setelah container di-restart. Ini pengujian terpenting pada bagian ini, karena tanpa volume kerja, seluruh layer hasil unggahan akan hilang setiap kali container dibuat ulang.

Restart container satu kali untuk membuktikan hal ini:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && sudo docker compose restart geoserver'

Ulangi perintah WFS setelah GeoServer selesai boot. Jika layer hilang setelah restart, catat sebagai temuan karena volume tidak terpasang dengan benar.

Ambil alamat IP eksternal:

Cloud Shell asisten
EXTERNAL_IP="$(gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)')"
echo "http://${EXTERNAL_IP}/portal"
curl -sSIL --max-redirs 3 "http://${EXTERNAL_IP}/portal"

Buka URL yang dicetak oleh perintah tersebut. Aplikasi tidak memakai trailingSlash, jadi /portal adalah bentuk kanoniknya. Bentuk berslash tetap bekerja karena dialihkan, tetapi menambah satu lompatan.

Geoportal berhasil diakses melalui alamat IP VM

Gambar 6. Geoportal hasil deployment dapat diakses melalui browser.

Catat keadaan VM sebelum ada perubahan konfigurasi apa pun. Data inilah yang menentukan apakah heap -Xmx2048m cukup untuk e2-medium. Pengukuran pertama pada 11 September 2026 sudah menunjukkan hasil yang lega, dan angkanya tersedia sebagai kolom acuan pada workbook asisten trainer. Tugasmu adalah memastikan VM sendiri sejauh itu juga.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='free -h && sudo docker stats --no-stream && sudo docker inspect --format "{{.Name}} OOMKilled={{.State.OOMKilled}}" geoserver_app'

Isi hasilnya ke tabel bukti pada workbook asisten trainer. Jika OOMKilled=true atau memori tersisa di bawah 300 MB, turunkan heap GeoServer dengan mengubah MAXIMUM_MEMORY menjadi 1024m, lalu jalankan sudo docker compose up -d geoserver. Jangan mengubah tipe mesin sebelum temuan ini dicatat lebih dahulu. Pengukuran lima VM serentak ada pada bagian 12 di workbook asisten trainer.

Bagian ini dijalankan kalau VM perlu dibuka lewat nama domain. Ada dua jalan, dan yang pertama jauh lebih murah.

Alamat IP statis sudah dikunci pada bagian 10, jadi record DNS di bawah ini tidak akan patah saat VM dimatikan dan dinyalakan kembali.

Pakai domain atau subdomain yang sudah ada

Section titled “Pakai domain atau subdomain yang sudah ada”

Ini jalur yang paling murah, dan sudah dibuktikan pada 12 September 2026. Tidak ada registrasi baru, tidak ada biaya tahunan, dan Cloud DNS tidak diperlukan. Cukup domain yang sudah dimiliki, bisa dari registrar mana pun.

  1. Pastikan alamat IP statis sudah terkunci seperti pada bagian 10.

  2. Buka pengelola DNS di tempat domain itu didaftarkan.

  3. Tambahkan record berikut. Ganti webgis dengan nama subdomain pilihanmu.

    Nama Jenis TTL Isi
    webgis A 300 alamat IP statis VM
    www.webgis bila perlu CNAME 300 webgis.contoh.com. dengan titik di akhir
  4. Tunggu propagasi, lalu periksa memakai perintah pada bagian di bawah.

Subdomain lebih disarankan daripada domain utama, karena satu domain bisa dipakai beberapa asisten dengan nama berbeda tanpa saling menimpa.

Ini jalur termurah untuk pelatihan berjamaah. Lembaga penyelenggara membeli satu domain untuk semua orang, misalnya gisbigtrainer.com, lalu setiap asisten memakai satu subdomain unik seperti nama01.gisbigtrainer.com. Biaya tahunannya satu domain saja, bukan satu domain per peserta.

Yang perlu disiapkan pemilik domain:

  1. Satu domain yang DNS-nya bisa diatur. Arahkan nameserver-nya ke Cloudflare kalau ingin HTTPS gratis tanpa sertifikat di VM.
  2. Satu record A per asisten, dengan nama subdomain sesuai slug dan isi alamat IP statis asisten itu.
  3. TTL 300 detik supaya perubahan cepat tersebar.

Alurnya berjalan dua arah, jadi tetapkan tenggat sebelum hari pelatihan. Asisten tidak bisa menyelesaikan bagian ini sendirian.

Langkah Siapa Yang dilakukan
1 Asisten Buat VM, kunci alamat IP statisnya, lalu catat alamatnya
2 Asisten Laporkan slug dan alamat IP statis ke koordinator
3 Pemilik domain Tambahkan record A bernama slug menuju alamat IP itu
4 Asisten Periksa hasilnya dengan dig, lalu lanjut ke HTTPS
Cloud Shell asisten
SUBDOMAIN="nama01.gisbigtrainer.com"
dig +short "$SUBDOMAIN"
curl -sSIL --max-redirs 3 "http://${SUBDOMAIN}/portal"

Hasil dig harus sama persis dengan alamat IP statis VM sendiri. Kalau berbeda, bandingkan dengan resolver publik sebelum menyalahkan recordnya:

Cloud Shell asisten
dig +short "$SUBDOMAIN" @1.1.1.1

Kalau resolver publik mengembalikan alamat yang benar sedangkan resolver di perangkatmu tidak, berarti masalahnya di jaringan lokal, bukan di recordnya. Jaringan rumah dengan AdGuard, Pi-hole, atau router yang mengarahkan seluruh subdomain ke satu server akan menang atas record publik. Untuk memastikan VM-nya sendiri benar, paksa alamatnya:

Terminal laptop
curl -sS -o /dev/null -w "ip=%{remote_ip} kode=%{http_code}\n" \
--resolve "$SUBDOMAIN:443:ALAMAT_IP_STATIS" \
"https://${SUBDOMAIN}/portal"

Balasan 200 dengan ip sama dengan alamat statis berarti VM sehat. Uji juga dari ponsel memakai data seluler, karena di sana tidak ada resolver lokal yang mengganggu.

Jalur ini hanya perlu diambil kalau belum ada domain sama sekali. Harga bergantung pada akhiran domain yang dipilih, registrasi memiliki komitmen satu tahun, dan perpanjangan otomatis aktif secara default. Cloud DNS ditagih terpisah.

Cloud Domains tidak mengirim hak akses ke Billing Account kepada peserta pelatihan. Biaya domain masuk ke Billing Account yang sudah terhubung dengan project. Registrasi domain tidak dapat dipindahkan ke project GCP lain setelah dibuat.

  1. Buka Cloud Domains pada Google Cloud Console.
  2. Pilih Register domain.
  3. Terima persyaratan Squarespace jika diminta.
  4. Cari nama domain dan periksa harga tahunannya.
  5. Pilih Use Cloud DNS dan buat zone baru dengan nama sesuai DNS_ZONE_NAME.
  6. Isi data kontak registrant dengan data yang benar.
  7. Periksa kembali biaya, lalu selesaikan registrasi.
  8. Buka email registrant dan selesaikan verifikasi maksimal 15 hari setelah registrasi.

Dokumentasi resmi tersedia pada panduan registrasi Cloud Domains dan harga Cloud Domains.

Ambil alamat statis yang sudah dikunci pada bagian 10:

Cloud Shell asisten
gcloud compute addresses describe "$STATIC_IP_NAME" \
--region="$VM_REGION" \
--format="value(address)"

Tambahkan record pada pengelola DNS yang kamu pakai. Kalau domainnya didaftarkan lewat Cloud Domains, pengelolanya ada pada Network services, lalu Cloud DNS. Kalau kamu memakai domain atau subdomain yang sudah ada, pengelolanya adalah situs registrar domain itu.

DNS name Jenis TTL Data
Nama domain atau subdomain A 300 alamat statis VM
www CNAME 300 nama domain utama dengan titik di akhir

Contoh nilai CNAME untuk domain contoh.com adalah contoh.com.. Tunggu propagasi, lalu periksa dari Cloud Shell:

Cloud Shell asisten
DOMAIN_NAME="contoh.com"
dig +short "$DOMAIN_NAME"
curl -sSIL --max-redirs 3 "http://${DOMAIN_NAME}/portal"

Jika record sudah resolve, buka http://DOMAIN_ANDA/portal. Alamat ini tidak akan berubah saat VM dimatikan dan dinyalakan kembali, karena alamat IP-nya sudah statis.

Bagian ini menerbitkan sertifikat Let’s Encrypt langsung ke VM masing-masing. Setiap asisten memegang sertifikatnya sendiri, dan tidak ada lapisan perantara yang menyembunyikan cara kerjanya.

Repository fork sudah menyiapkan tiga hal yang diperlukan, yaitu direktori tantangan ACME, direktori untuk arahan TLS, dan include pada nginx.conf yang membaca direktori itu. Direktori TLS sengaja dibiarkan kosong, sehingga deployment pertama tetap berjalan sebagai HTTP tanpa perubahan apa pun.

Langkah 1, pasang Certbot dan siapkan dua direktori.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='sudo apt-get update && sudo apt-get install -y certbot && cd /opt/webgis/app && mkdir -p certbot-webroot tls'

Langkah 2, sinkronkan konfigurasi dan nyalakan ulang.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && git pull --ff-only && sudo docker compose up -d && sudo docker compose ps'

Pastikan nginx_proxy berstatus running. Pada tahap ini situs masih dilayani lewat HTTP, dan itu memang yang diinginkan.

Langkah 3, terbitkan sertifikat.

Cloud Shell asisten
SUBDOMAIN="nama01.gisbigtrainer.com"
EMAIL="nama01@example.com"
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command="cd /opt/webgis/app && sudo certbot certonly --webroot -w /opt/webgis/app/certbot-webroot -d $SUBDOMAIN --non-interactive --agree-tos -m $EMAIL"

Domain harus sudah mengarah ke alamat IP statis VM sebelum langkah ini. Kalau dig +short belum mengembalikan alamat yang benar, tunggu propagasi lebih dahulu.

Sebelum menerbitkan, periksa bahwa jalur tantangan ACME sudah dilayani Nginx dan bukan diteruskan ke aplikasi:

Cloud Shell asisten
curl -sS -o /dev/null -w "acme %{http_code}\n" "http://${SUBDOMAIN}/.well-known/acme-challenge/uji"

Balasan 404 adalah hasil yang benar, artinya Nginx mencari berkasnya di direktori tantangan dan tidak menemukannya, persis seperti yang seharusnya. Kalau yang muncul 502 atau 200, berarti permintaan itu jatuh ke aplikasi Next.js dan blok location untuk ACME belum ikut terpasang. Perbaiki dulu sebelum menjalankan Certbot, karena kegagalan di sini akan muncul sebagai pesan Timeout during connect yang menyesatkan.

Langkah 4, nyalakan HTTPS.

Satu berkas baru sudah cukup, dan berkas itu tidak ikut Git sehingga git pull berikutnya tetap berjalan tanpa konflik:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command="printf '%s\n' 'listen 443 ssl;' 'ssl_certificate /etc/letsencrypt/live/$SUBDOMAIN/fullchain.pem;' 'ssl_certificate_key /etc/letsencrypt/live/$SUBDOMAIN/privkey.pem;' | sudo tee /opt/webgis/app/tls/aktifkan.conf >/dev/null && cd /opt/webgis/app && sudo docker compose exec -T nginx nginx -t && sudo docker compose exec -T nginx nginx -s reload"

Perintah itu memeriksa sintaksnya lebih dahulu, lalu memuat ulang Nginx. Kalau nginx -t gagal, Nginx tidak jadi dimuat ulang dan situs HTTP tetap hidup, sehingga tidak ada yang mati akibat salah ketik.

Langkah ini cukup memakai nginx -s reload, tanpa membuat ulang container, karena tls/aktifkan.conf berada di dalam direktori yang dipasang. Berkas baru di dalam direktori yang dipasang langsung terlihat. Ini berbeda dari nginx.conf yang dipasang sebagai berkas tunggal, dan perbedaannya dibahas pada bagian 28.

Periksa dari luar:

Cloud Shell asisten
curl -sSIL --max-redirs 3 "https://${SUBDOMAIN}/portal"

Yang benar adalah balasan 200 pada alamat berawalan https://.

Kalau permintaan HTTPS gagal, periksa dua hal ini lebih dahulu:

  1. Firewall allow-webgis-http membuka port 80 dan 443. Kalau hanya 80, Let’s Encrypt tetap bisa memverifikasi, tetapi HTTPS tidak akan bisa dibuka.
  2. Berkas /opt/webgis/app/tls/aktifkan.conf benar-benar ada dan nama direktori sertifikatnya sama dengan subdomain sendiri.

Langkah 5, perpanjangan otomatis.

Sertifikat Let’s Encrypt berlaku 90 hari. Certbot memasang timer perpanjangan sendiri, tetapi container Nginx perlu dimuat ulang setiap sertifikat diperbarui. Buat skrip hook berikut:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy && printf "%s\n" "#!/bin/sh" "cd /opt/webgis/app && docker compose exec -T nginx nginx -s reload" | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null && sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh && sudo certbot renew --dry-run'

--dry-run memakai server percobaan Let’s Encrypt, jadi aman dijalankan berulang kali dan tidak memakai jatah penerbitan sebenarnya.

Tidak ada role GCP tambahan untuk alur Let’s Encrypt atau Certbot pada modul. Role roles/compute.osAdminLogin memberi peserta pelatihan akses administratif pada VM. Firewall bersama allow-webgis-http sudah membuka port 80 dan 443 untuk VM dengan network tag webgis-http. Jangan membuat firewall HTTPS baru untuk setiap asisten.

Deployment yang telah diuji menjalankan Nginx di dalam container. Perintah certbot --nginx pada host dari modul tidak dapat langsung dipakai karena plugin tersebut mencari Nginx host, sedangkan port 80 dipakai container.

Lihat juga catatan Modul 5.

Google Certificate Manager tidak digunakan pada alur modul ini. Karena itu, roles/certificatemanager.editor dan Certificate Manager API tidak perlu diberikan.

Jika domain hanya dipakai untuk pengujian dan tidak ingin diperpanjang tahun berikutnya, matikan perpanjangan otomatis pada pengaturan registrasi Cloud Domains setelah pengujian. Biaya periode satu tahun yang sudah dibeli tetap berlaku.

Tiga hal perlu dicadangkan, dan hanya dua di antaranya berada di VM.

Yang dicadangkan Letaknya Keadaan sekarang
docker-compose.yml, nginx.conf, cloudbuild.yaml, robots.js, sitemap.js fork di GitHub tercadang otomatis setiap push
Berkas .env /opt/webgis/app/.env di VM disalin manual ke pengelola kata sandi
Folder geoserver-data /opt/webgis/app/geoserver-data di VM dicadangkan harian ke Cloud Storage
Cloud Shell asisten
BUCKET="cadangan-webgis-${PROJECT_ID}"
gcloud storage buckets create "gs://${BUCKET}" \
--location=asia-southeast2 \
--uniform-bucket-level-access

Nama bucket harus unik di seluruh dunia. Kalau nama itu sudah dipakai orang lain, tambahkan slug asisten di belakangnya.

Ganti PROJECT_ID dan SLUG_ASISTEN dengan milik sendiri:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" --zone="$ZONE" --tunnel-through-iap --command='
sudo tee /etc/cron.daily/cadangkan-webgis >/dev/null <<"EOF"
#!/bin/sh
set -e
BUCKET="cadangan-webgis-PROJECT_ID"
SLUG="SLUG_ASISTEN"
STAMP=$(date +%F)
cd /opt/webgis/app
tar -czf /tmp/geoserver-data-$STAMP.tar.gz geoserver-data
gcloud storage cp /tmp/geoserver-data-$STAMP.tar.gz gs://$BUCKET/$SLUG/
rm -f /tmp/geoserver-data-$STAMP.tar.gz
EOF
sudo chmod +x /etc/cron.daily/cadangkan-webgis
'

Jalankan sekali untuk membuktikan skripnya bekerja, lalu periksa isi bucket:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" --zone="$ZONE" --tunnel-through-iap \
--command='sudo /etc/cron.daily/cadangkan-webgis && echo "skrip selesai tanpa error"'
gcloud storage ls -l "gs://${BUCKET}/${SLUG_ASISTEN}/"

Nama berkasnya memakai tanggal, jadi pencadangan di hari yang sama menimpa arsip sebelumnya. Itu memang yang diinginkan: satu arsip per hari, bukan menumpuk belasan.

Jalankan seluruh blok berikut. Sesuaikan nama arsipnya dengan yang ada di bucket.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" --zone="$ZONE" --tunnel-through-iap --command='
cd /opt/webgis/app
BUCKET="cadangan-webgis-PROJECT_ID"
SLUG="SLUG_ASISTEN"
ARCHIVE="geoserver-data-2026-09-12.tar.gz"
gcloud storage cp "gs://$BUCKET/$SLUG/$ARCHIVE" /tmp/
sudo docker compose stop geoserver
sudo tar -xzf /tmp/$ARCHIVE
sudo docker compose up -d --force-recreate geoserver
'

Tiga hal yang perlu diperhatikan pada blok itu.

Ekstrak selalu dijalankan dari /opt/webgis/app, supaya isi arsip mendarat di tempat yang benar. Arsip itu memuat jalur geoserver-data/... di dalamnya.

--force-recreate diperlukan. Tanpa itu Compose hanya menyalakan kembali container lama dengan mount yang sudah ada.

Kepemilikan berkas biasanya sudah benar tanpa langkah tambahan. tar yang dijalankan sebagai root mempertahankan kepemilikan yang tersimpan di dalam arsip, dan arsipnya dibuat dari folder yang sama. Kalau GeoServer menolak menulis ke data directory-nya setelah pemulihan, samakan kepemilikannya dengan folder yang sedang dipakai container:

Terminal window
sudo chown -R --reference=/opt/webgis/app/geoserver-data /opt/webgis/app/geoserver-data

Tunggu sekitar satu menit supaya GeoServer selesai boot, lalu buktikan layernya terbaca kembali lewat WFS memakai perintah pada bagian 21. Kalau jawabannya memuat FeatureCollection, pemulihannya berhasil.

Berkas ini tidak ikut ke bucket karena memuat kata sandi dan kunci rahasia. Salin manual:

Cloud Shell asisten
gcloud compute scp \
--zone="$ZONE" \
--tunnel-through-iap \
"$VM_NAME:/opt/webgis/app/.env" "env-$PARTICIPANT_ID.txt"

Simpan berkas itu di tempat yang aman, misalnya pengelola kata sandi. Jangan memasukkannya ke Git, dan jangan menempelkan isinya ke laporan atau tangkapan layar.

Bagian ini memeriksa apakah deployment masih sehat setelah dipakai beberapa waktu.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && sudo docker compose ps && sudo docker stats --no-stream && df -h / && free -h'
Yang dibaca Nilai sehat Tindakan bila menyimpang
docker compose ps tiga container berstatus running lihat bagian 29
Memori GeoServer jauh di bawah batas 2048 MiB, acuan terukur 882 MiB turunkan MAXIMUM_MEMORY seperti pada bagian 29
Memori tersedia di atas 300 MB turunkan heap, atau hentikan container yang tidak dipakai
Pemakaian disk / di bawah 80% hapus arsip lama, lalu periksa ukuran geoserver-data
OOMKilled ketiga container false cari pemakai memori yang sebenarnya sebelum mengubah konfigurasi
Cloud Shell asisten
EXTERNAL_IP="$(gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)')"
curl -sS -o /dev/null -w "portal %{http_code}\n" "http://${EXTERNAL_IP}/portal"
curl -sS -o /dev/null -w "geoserver %{http_code}\n" "http://${EXTERNAL_IP}/geoserver/web"

Hasil yang benar adalah 200 untuk portal dan 302 untuk GeoServer.

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && sudo docker compose logs --tail=50 geoserver'

Ketiga service memakai restart: unless-stopped, jadi container menyala sendiri saat VM boot. GeoServer tetap butuh waktu sekitar 47 detik sebelum siap melayani. Jangan menyimpulkan ada kerusakan sebelum Server startup in [...] milliseconds muncul di log.

Jalankan perintah di Cloud Shell pada akun dan project tujuan, bukan terminal lokal atau shell di dalam VM. Untuk project bersama, pilih hanya VM, check, dan policy milik sendiri. Jangan menghapus seluruh resource monitoring project untuk mengulang pengujian.

Isi variabel berikut sesuai deployment dan email yang benar-benar dibuka:

Cloud Shell asisten
export PROJECT_ID="geoportal-kelompok-a-xxxxx"
export VM_NAME="webgis-nama01"
export ZONE="asia-southeast2-b"
export DOMAIN="ujissl.nama01.it.com"
export ALERT_EMAIL="ALAMAT_EMAIL_PENERIMA"
gcloud monitoring uptime list-configs --project="$PROJECT_ID" \
--format='table(displayName,name,period)'
gcloud alpha monitoring channels list --project="$PROJECT_ID" \
--format='json(name,displayName,type,labels.email_address,enabled)'

Jika check untuk domain tersebut sudah ada, gunakan ID-nya dan ubah interval menjadi satu menit:

Cloud Shell asisten
export CHECK_ID="uji-portal-sTmhxgyyrQk"
gcloud monitoring uptime update "$CHECK_ID" --project="$PROJECT_ID" --period=1

Jika belum ada, buat sekali, lalu salin segmen terakhir name hasilnya ke CHECK_ID:

Cloud Shell asisten
gcloud monitoring uptime create "portal-${VM_NAME}" \
--project="$PROJECT_ID" \
--resource-type=uptime-url \
--resource-labels="host=${DOMAIN},project_id=${PROJECT_ID}" \
--protocol=https --path=/portal --port=443 \
--period=1 --timeout=10 --validate-ssl=true \
--regions=asia-pacific,europe,usa-iowa

--period=1 berarti satu menit, bukan satu detik. HTTPS memakai hostname agar TLS/SNI sesuai sertifikat. Pemeriksaan HTTP sukses tidak membuktikan isi halaman atau seluruh backend sehat. Next.js yang mati bisa menghasilkan 502 melalui Nginx dan terdeteksi; GeoServer atau database yang mati belum tentu mengubah respons /portal.

Gunakan channel email yang sudah ada dengan mengisi CHANNEL memakai nama resource lengkap. Jika belum ada, buat sekali:

Cloud Shell asisten
CHANNEL="$(gcloud alpha monitoring channels create \
--project="$PROJECT_ID" --display-name="Email asisten" --type=email \
--channel-labels="email_address=${ALERT_EMAIL}" --format='value(name)')"

Pastikan channel aktif, alamat email benar, dan selesaikan verifikasi jika diminta Console.

Gunakan konfigurasi berikut untuk alert akses portal:

Pengaturan Nilai
Metric monitoring.googleapis.com/uptime_check/check_passed
Resource dan filter uptime_url, check ID milik portal tujuan
Alignment 60s, ALIGN_NEXT_OLDER
Reducer REDUCE_COUNT_FALSE
Kondisi Above threshold, COMPARISON_GT, threshold 1
Trigger Any time series, count: 1
Retest No retest, duration: 0s untuk pengujian
Status Enabled
Notification Email saat open dan closure

REDUCE_COUNT_FALSE menghitung time series lokasi yang gagal setelah alignment. Pada tiga lokasi, sehat semua = 0, gagal semua = 3. > 1 berarti minimal dua lokasi gagal.

Di Monitoring → Alerting, edit policy milik sendiri atau buat melalui uptime check. Isi tabel di atas, pilih channel email, aktifkan notifikasi pembukaan dan penutupan, lalu simpan dan Enable. Tetapkan alignment 60s dan notifikasi open dan closure secara eksplisit sebelum menyimpan.

Alternatif untuk membuat policy baru melalui Cloud Shell (jangan jalankan jika sedang memperbaiki policy yang sudah ada):

Cloud Shell asisten
POLICY_NAME="$(gcloud alpha monitoring policies create \
--project="$PROJECT_ID" --display-name="Portal ${VM_NAME} tidak tersedia" \
--notification-channels="$CHANNEL" \
--condition-display-name="Minimal dua lokasi gagal" \
--condition-filter="metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.labels.check_id=\"${CHECK_ID}\"" \
--aggregation='{"alignmentPeriod":"60s","perSeriesAligner":"ALIGN_NEXT_OLDER","crossSeriesReducer":"REDUCE_COUNT_FALSE"}' \
--if='> 1' --trigger-count=1 --duration=0s --combiner=OR \
--format='value(name)')"

Sesudah membuat lewat CLI, buka policy di Console dan pastikan Notify on alert open and closure. Setelah konfigurasi diperiksa, lakukan pengujian di bawah.

Verifikasi konfigurasi yang tersimpan; salin POLICY_NAME dari daftar jika memakai Console:

Cloud Shell asisten
gcloud alpha monitoring policies list --project="$PROJECT_ID" \
--format='table(name,displayName,enabled)'
gcloud alpha monitoring policies describe "$POLICY_NAME" --project="$PROJECT_ID" \
--format='json(name,enabled,conditions,notificationChannels,alertStrategy)'

Pastikan COMPARISON_GT, threshold 1, trigger count 1, duration 0s, dan enabled true. Nonaktifkan hanya policy percobaan milik sendiri yang tidak dipakai. Beri jeda setidaknya lima menit setelah perubahan, lalu pastikan ketiga lokasi hijau dan URL membalas 200. Jeda ini adalah langkah operasional, bukan jaminan waktu propagasi Google.

Siapkan perintah pemulihan sebelum mulai. Stop Nginx saja, bukan VM, agar SSH tetap tersedia. Bedanya dijelaskan pada kotak di bagian 30. Catat jam stop dan pastikan URL yang sama dengan uptime check benar-benar gagal:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" --project="$PROJECT_ID" --zone="$ZONE" \
--tunnel-through-iap \
--command='sudo docker stop nginx_proxy && date -u "+NGINX STOP: %Y-%m-%d %H:%M:%S UTC"'
curl --silent --show-error --output /dev/null --write-out 'HTTP %{http_code}\n' \
--max-time 15 "https://${DOMAIN}/portal"

Exited dan gagal koneksi dengan HTTP 000 adalah hasil yang diharapkan. Jika masih 200, periksa target URL sebelum menyalahkan policy. Pantau Last 1 hour, lalu periksa lokasi uptime check, incident baru setelah waktu stop, dan email Alert firing. Cocokkan nama policy dan waktu incident pada email dengan pengujian ini.

Setelah email masuk, atau maksimal sepuluh menit jika belum masuk, pulihkan:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" --project="$PROJECT_ID" --zone="$ZONE" \
--tunnel-through-iap \
--command='sudo docker start nginx_proxy && date -u "+NGINX START: %Y-%m-%d %H:%M:%S UTC"'
curl --silent --show-error --output /dev/null --write-out 'HTTP %{http_code}\n' \
--max-time 20 "https://${DOMAIN}/portal"

docker start menjalankan kembali container beserta prosesnya; tidak perlu membuat ulang container untuk pemulihan biasa. Tunggu HTTP 200, lokasi hijau, incident yang sama otomatis Closed, dan email Alert recovered. Jangan tutup manual incident yang sedang diuji. Durasi di email adalah durasi incident, bukan latensi email atau durasi downtime persis.

Catat waktu stop, hasil HTTP, waktu incident terbuka, waktu start, dan email pemulihan pada workbook, lalu simpan hasil di salinan per asisten.

Untuk mengurangi alarm akibat gangguan sesaat, retest dapat diubah menjadi dua menit setelah pengujian awal selesai. Ulangi uji stop dan pemulihan setelah mengubah konfigurasi.

  • Pastikan project, check ID, policy ID, dan channel cocok; nama saja tidak cukup.
  • Tampilkan Show closed alerts dan pilih rentang waktu pengujian.
  • Acknowledged tidak sama dengan Closed. Jika penutupan ditolak karena active conditions, simpan bukti dan jangan membuat gangguan hanya untuk memaksa penutupan.
  • Tutup dulu incident yang sedang terbuka sebelum mengubah condition policy. Ini bukan saran gaya kerja, melainkan menghindari bug Cloud Monitoring yang diakui Google pada issue tracker 183505672: incident yang terbuka saat condition diubah akan terus berbunyi memakai konfigurasi lama, sehingga muncul sebagai false positive dan tidak bisa ditutup manual. Kalau itu terjadi, incident akan menutup sendiri setelah 7 hari. Akibat praktisnya, incident lama yang menggantung jangan dipakai sebagai bukti apa pun tentang konfigurasi baru. Buat kondisi bersih lebih dahulu, baru nilai hasilnya.
  • Temuan dan koreksi terpusat di troubleshooting.

Uptime check dibuat per endpoint peserta; satu project kelompok bisa memiliki beberapa check dan policy. Channel email boleh dipakai bersama sesuai penerima yang dikehendaki. Untuk interval satu menit dan tiga lokasi, perkiraan jadwal pemeriksaan adalah 129.600 per 30 hari per endpoint. Periksa kuota dan harga Monitoring sebelum memperbanyak check; angka ini bukan bukti jumlah eksekusi yang ditagihkan.

Rujukan: policy uptime, contoh JSON, dan troubleshooting Google.

Bagian ini membuat geoportal dapat ditemukan mesin pencari. Ada tiga hal yang dikerjakan, dan jenisnya berbeda-beda. Memisahkannya penting supaya tidak ada yang tertukar.

Jenis Yang dikerjakan Di mana Kapan
Berkas baru src/app/robots.js dan src/app/sitemap.js Fork, ikut terbawa ke dalam image Sekali, lalu build ulang
Konfigurasi Dua blok location pada nginx.conf Fork, lalu disalin ke VM Sekali, sebelum verifikasi
Pendaftaran Properti Domain di Google Search Console Peramban, di luar repo Terakhir, setelah keduanya jalan

Bagian ini dikerjakan paling akhir, setelah HTTPS pada bagian 24 selesai. Alasannya: mesin pencari menuntut alamat yang tetap, dan alamat itu baru ada setelah domain mengarah ke VM. Pendaftaran ke mesin pencari hanya masuk akal bila alamatnya sudah berupa domain, bukan alamat IP.

Periksa dari luar VM bahwa halaman portal terbuka tanpa login:

Cloud Shell asisten
curl -sS -o /dev/null -w "portal %{http_code}\n" "http://DOMAIN/portal"
curl -sS -o /dev/null -w "internal %{http_code}\n" "http://DOMAIN/portal/internal"

Halaman portal harus membalas 200, sedangkan /portal/internal memang meminta login. Ganti DOMAIN dengan domain yang didaftarkan pada bagian 24.

Next.js menyediakan kedua berkas itu lewat konvensi App Router. Buat dua berkas berikut di fork:

src/app/robots.js
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/portal/internal/', '/portal/api/']
},
sitemap: 'https://DOMAIN/portal/sitemap.xml'
};
}
src/app/sitemap.js
export default function sitemap() {
const base = 'https://DOMAIN/portal';
return [
{ url: base + '/', priority: 1 },
{ url: base + '/peta-latihan-1', priority: 0.8 }
];
}

Ganti DOMAIN dengan domain sendiri, lalu commit dan push supaya Cloud Build membangun ulang. Kedua berkas ini ikut masuk ke dalam image, jadi tidak perlu disalin manual ke VM.

Jangan mendaftar ke Search Console sebelum tiga pemeriksaan ini benar. Kalau salah satunya gagal, pendaftarannya ditolak dengan pesan yang tidak menyebut penyebabnya.

Cloud Shell asisten
curl -sS "https://DOMAIN/robots.txt"
curl -sS "https://DOMAIN/sitemap.xml"
curl -sS -o /dev/null -w "internal %{http_code}\n" "https://DOMAIN/portal/internal"
Perintah Hasil yang benar
robots.txt Memuat baris Disallow: /portal/internal/ dan baris Sitemap:
sitemap.xml Berisi XML dengan minimal satu alamat halaman publik
/portal/internal Bukan 200, karena halaman admin memang meminta login

Dua perintah pertama adalah yang paling sering gagal, dan penyebabnya selalu sama: blok location pada langkah sebelumnya belum ikut tersalin ke VM.

  1. Buka https://search.google.com/search-console dan masuk memakai akun pemilik domain.

  2. Pilih Add property, lalu pilih jenis Domain dan isi domain tanpa http://.

  3. Verifikasi kepemilikan lewat record TXT pada Cloud DNS. Salin nilai TXT yang diberikan Google, lalu tambahkan pada zone yang dibuat di bagian 24:

    Cloud Shell asisten
    gcloud dns record-sets create "DOMAIN." \
    --zone="$DNS_ZONE_NAME" \
    --type="TXT" \
    --ttl="300" \
    --rrdatas='"nilai-verifikasi-dari-google"'
  4. Tunggu propagasi record, lalu tekan Verify.

  5. Setelah terverifikasi, kirim sitemap lewat menu Sitemaps dengan isi sitemap.xml.

Verifikasi juga bisa memakai berkas HTML, tetapi cara itu menuntut berkas diletakkan di akar domain, sedangkan akar domain pada deployment ini dipakai Nginx. Record TXT lebih sederhana.

28. Sinkronkan file konfigurasi VM bila berubah

Section titled “28. Sinkronkan file konfigurasi VM bila berubah”

Cloud Build membangun image aplikasi, tetapi docker-compose.yml, .env, dan nginx.conf berada pada clone repository di VM. Perubahan file tersebut tidak otomatis masuk ke VM hanya karena image baru berhasil dibangun.

Jika file konfigurasi repository berubah setelah clone awal, sinkronkan dan buat ulang container Nginx:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && git pull --ff-only && sudo docker compose up -d --force-recreate nginx && sudo docker compose exec -T nginx nginx -t'

Direktori yang dipasang, seperti tls/ dan certbot-webroot/, tidak mengalami masalah ini karena berkas baru di dalam direktori langsung terlihat tanpa membuat ulang container.

Jangan menjalankan git pull jika clone di VM memiliki perubahan lokal yang belum disimpan. Periksa dahulu dengan git status --short.

29. Troubleshooting berdasarkan hasil pengujian

Section titled “29. Troubleshooting berdasarkan hasil pengujian”

Gejala:

The policy contains bindings with conditions, so specifying a condition is required

Penyebabnya adalah project telah memiliki binding bersyarat, biasanya dari koneksi Cloud Build. Tambahkan --condition=None pada binding baru. Ini menyatakan bahwa role baru tidak memakai kondisi.

Trigger menampilkan could not parse resource []

Section titled “Trigger menampilkan could not parse resource []”

Penyebabnya adalah _VM_NAME atau _VM_ZONE kosong. Buka konfigurasi trigger dan isi empat substitution variable pada bagian 16. Error ini bukan masalah firewall atau SSH.

SSH menampilkan Permission denied (publickey)

Section titled “SSH menampilkan Permission denied (publickey)”

Cloud Build sudah mencapai IAP, tetapi Service Account deployment belum boleh bertindak pada Service Account VM. Jalankan kembali binding berikut:

Cloud Shell asisten
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" \
--format='value(projectNumber)')"
VM_SA="${PROJECT_NUMBER}-asisten01@example.com"
gcloud iam service-accounts add-iam-policy-binding "$VM_SA" \
--member="serviceAccount:$BUILD_SA" \
--role="roles/iam.serviceAccountUser" \
--condition=None

Artifact Registry menampilkan Unauthenticated request

Section titled “Artifact Registry menampilkan Unauthenticated request”

Pastikan Service Account VM memiliki roles/artifactregistry.reader:

Cloud Shell asisten
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$VM_SA" \
--role="roles/artifactregistry.reader" \
--condition=None

Deployment yang diuji memakai access token singkat dari identitas VM. Google merekomendasikan access token atau credential helper dibanding Service Account key JSON. Lihat autentikasi Docker untuk Artifact Registry.

Jika login berhasil tetapi pull masih unauthenticated, periksa pengguna yang menjalankan Docker. Perintah sudo docker membaca /root/.docker/config.json, bukan konfigurasi Docker milik user biasa. cloudbuild.yaml final memakai sudo gcloud auth print-access-token, sudo docker login, dan sudo -H docker compose agar kredensial dibaca oleh user yang sama.

Gejala:

required variable NEXTJS_IMAGE is missing a value

Trigger belum berhasil menyimpan tag image ke /opt/webgis/app/.env. Jalankan trigger sampai seluruh step hijau. Setelah itu, periksa nilai tanpa menampilkan isi secret lain:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command="sudo grep '^NEXTJS_IMAGE=' /opt/webgis/app/.env"

Browser menampilkan ERR_TOO_MANY_REDIRECTS

Section titled “Browser menampilkan ERR_TOO_MANY_REDIRECTS”

Masalah ini berasal dari perbedaan URL kanonik antara Nginx dan Next.js, bukan dari browser. Konfigurasi final repository memakai basePath: "/portal" dan tanpa trailingSlash, sedangkan Nginx memproksi seluruh path melalui location /.

Pastikan VM memakai commit terbaru dan muat ulang Nginx dengan perintah pada bagian 28. Kemudian akses http://IP_EKSTERNAL_VM/portal. Menghapus cookie hanya diperlukan jika browser masih menyimpan redirect lama setelah konfigurasi server dipastikan benar.

Container GeoServer mati sendiri atau keluar dengan kode 137

Section titled “Container GeoServer mati sendiri atau keluar dengan kode 137”

Gejala:

geoserver_app Exited (137)

Periksa apakah container dihentikan oleh kernel karena kehabisan memori:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='sudo docker inspect --format "{{.State.OOMKilled}}" geoserver_app && free -h'

Nilai true berarti kernel menghentikan container karena memori habis. Pada pengukuran 11 September 2026 hal itu tidak terjadi: dengan e2-medium dan MAXIMUM_MEMORY=2048m, GeoServer memakai 882 MiB, OOMKilled bernilai false, dan 2,3 GiB memori masih tersedia. Kalau nilai true justru muncul di VM-mu, periksa free -h dan docker stats --no-stream lebih dahulu untuk mencari pemakai memori yang sebenarnya, lalu turunkan heap kalau GeoServer memang penyebabnya:

docker-compose.yml
environment:
- INITIAL_MEMORY=256m
- MAXIMUM_MEMORY=1024m

Muat ulang container setelah berkas diubah:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && sudo docker compose up -d geoserver && sudo docker compose ps'

Catat nilai free -h sebelum dan sesudah perubahan pada workbook asisten trainer. Data ini yang menentukan apakah e2-medium tetap layak dipakai untuk pelatihan atau perlu dinaikkan ke e2-standard-2. Pengukuran pertama menjawabnya sementara ini dengan e2-medium yang masih lega.

Halaman /geoserver/web menampilkan 502 atau 404

Section titled “Halaman /geoserver/web menampilkan 502 atau 404”

Nginx container belum memuat rute GeoServer. Rute tersebut berasal dari nginx.conf pada repository fork. Pastikan commit berisi blok location /geoserver/, lalu sinkronkan berkas konfigurasi ke VM dan muat ulang Nginx seperti pada bagian 28.

Jika Nginx sudah benar tetapi tetap 502, periksa container tujuan:

Cloud Shell asisten
gcloud compute ssh "$VM_NAME" \
--zone="$ZONE" \
--tunnel-through-iap \
--command='cd /opt/webgis/app && sudo docker compose ps && sudo docker compose logs --tail=30 geoserver'

Kata sandi admin GeoServer kembali ke nilai bawaan setelah restart

Section titled “Kata sandi admin GeoServer kembali ke nilai bawaan setelah restart”

Kata sandi yang diubah lewat antarmuka web tidak bertahan karena image membaca GEOSERVER_ADMIN_PASSWORD setiap kali container dijalankan. Isi variabel tersebut pada docker-compose.yml, lalu jalankan sudo docker compose up -d geoserver.

Alert policy ditolak dengan groupByFields[0] had an invalid value

Section titled “Alert policy ditolak dengan groupByFields[0] had an invalid value”

Gejalanya:

INVALID_ARGUMENT: Field aggregation.groupByFields[0] had an invalid value of
"project_id": The group-by field is improperly formatted.

Antarmuka Cloud Monitoring menampilkan nama field dengan gaya project_id dan resource.label.host, sedangkan API menuntut project dan resource.labels.host. Bila kamu memang perlu mengelompokkan hasil, pakai bentuk yang kedua. Untuk satu host, cara paling sederhana adalah membuang groupByFields sama sekali seperti pada bagian 26.

Jalankan dari Cloud Shell agar biaya compute berhenti saat VM tidak digunakan:

Cloud Shell asisten
gcloud compute instances stop "$VM_NAME" \
--zone="$ZONE"

Nyalakan kembali saat melanjutkan latihan:

Cloud Shell asisten
gcloud compute instances start "$VM_NAME" \
--zone="$ZONE"

Setelah VM menyala kembali, alamat IP eksternal dapat berubah jika alamat tersebut belum direservasi. Ambil IP terbaru dan sesuaikan NEXTAUTH_URL bila diperlukan.

Bila VM akan dimatikan lama, bukan sekadar diistirahatkan semalam, hentikan juga pemantauan otomatisnya lebih dahulu. Alasannya, cara melakukannya, dan urutan pembersihan yang lengkap ada pada bagian 31.

31. Bersihkan semua resource setelah selesai

Section titled “31. Bersihkan semua resource setelah selesai”

Bagian ini dipakai pada akhir pelatihan, atau kapan pun kamu ingin berhenti memakai project tanpa meninggalkan tagihan yang berjalan diam-diam.

Ini bagian yang paling sering membuat kaget, karena VM yang berhenti bukan berarti tagihannya berhenti.

Resource Saat VM berjalan Saat VM berhenti
Compute e2-medium ditagih per detik tidak ditagih
Boot disk 30 GB pd-balanced sekitar $0,04 per GB per bulan tetap ditagih
Static IP yang menempel $0,005 per jam berubah menjadi $0,01 per jam
IP statis yang sudah dicopot tidak ditagih tidak ditagih
Bucket Cloud Storage ditagih per GB tetap ditagih, tetapi arsip 60 KB praktis nol
Artifact Registry sekitar $0,10 per GB per bulan tetap ditagih

Baris ketiga yang paling penting. Alamat IP statis yang menempel pada VM berstatus IN_USE dengan tarif $0,005 per jam. Begitu VM dihentikan, statusnya berubah menjadi menganggur dan tarifnya naik dua kali menjadi $0,01 per jam. Jadi menghentikan VM tanpa mencopot IP statisnya menghemat compute tetapi menambah tagihan IP.

Hitungan nyata pada 13 September 2026 untuk empat VM e2-medium dan tiga IP statis:

Pos Semua VM berjalan Semua VM berhenti, IP dicopot
Compute sekitar $98 per bulan $0
Disk 4 x 30 GB sekitar $5 per bulan sekitar $5 per bulan
IP statis 3 buah sekitar $11 per bulan $0
Total sekitar $114 per bulan sekitar $5 per bulan

Copot IP statisnya supaya tidak menagih selama menganggur:

Cloud Shell asisten
gcloud compute instances stop "$VM_NAME" --zone="$ZONE"
gcloud compute addresses delete "NAMA_IP_STATIS" \
--region="$REGION" \
--quiet

Konsekuensinya harus diterima: alamat IP-nya hilang dan tidak bisa dipesan kembali. Saat VM dinyalakan lagi, alamatnya akan berbeda, sehingga tiga hal ini harus disesuaikan, yaitu NEXTAUTH_URL pada .env, record DNS di Cloudflare, dan penerbitan ulang sertifikat Let’s Encrypt pada bagian 24. Ketiganya sudah pernah dijalankan dan tidak sulit, tetapi jangan dilupakan.

Kalau alamat yang tetap lebih penting daripada penghematan, biarkan IP statisnya menempel dan terima tarif $0,005 per jam selama VM berjalan. Yang tidak boleh dilakukan adalah menghentikan VM sambil membiarkan IP statisnya menganggur, karena itu keadaan paling mahal dari ketiganya.

Urutannya penting. Pemantauan lebih dahulu, lalu IP, terakhir baru VM dan disk.

Cloud Shell asisten
# 1. Hentikan pemantauan supaya tidak ada email sampah
gcloud alpha monitoring policies list --format="value(name)" | while read -r POLICY; do
gcloud alpha monitoring policies delete "$POLICY" --quiet
done
gcloud monitoring uptime list-configs --format="value(name)" | while read -r CHECK; do
gcloud monitoring uptime delete "$CHECK" --quiet
done
gcloud alpha monitoring channels list --format="value(name)" | while read -r CHANNEL; do
gcloud alpha monitoring channels delete "$CHANNEL" --quiet
done
# 2. Lepaskan IP statis sebelum VM dihapus
gcloud compute addresses list --format="value(name)" | while read -r ADDRESS; do
gcloud compute addresses delete "$ADDRESS" --region="$REGION" --quiet
done
# 3. Hapus VM, lalu disk-nya
for VM in NAMA_VM_1 NAMA_VM_2 NAMA_VM_3 NAMA_VM_4; do
gcloud compute instances delete "$VM" --zone="$ZONE" --quiet
done
gcloud compute disks list --format="table(name,sizeGb,users)"

Verifikasi bahwa pemantauan sudah bersih, dan pastikan ketiganya tidak menampilkan apa pun:

Cloud Shell asisten
gcloud alpha monitoring policies list --format="value(displayName)"
gcloud monitoring uptime list-configs --format="value(displayName)"
gcloud alpha monitoring channels list --format="value(displayName)"

Menghapus pemantauan tidak memengaruhi uptime check atau policy milik project lain, dan tidak menyentuh VM, disk, maupun bucket. Jadi langkah ini aman dijalankan lebih dahulu, dan justru dianjurkan sebelum VM dimatikan supaya tidak ada email peringatan yang datang selama layanannya sengaja berhenti.

Nama VM tertukar. Di project ini pernah ada empat VM dengan nama yang mirip, dan menulis satu nama yang salah berarti satu VM tertinggal dan tetap menagih. Jalankan gcloud compute instances list lebih dahulu, salin namanya apa adanya, jangan mengandalkan ingatan.

Menghapus VM tetapi membiarkan disk-nya. Kadang orang sengaja melakukannya untuk menyimpan data. Kalau memang itu maksudnya, tidak apa-apa, tetapi ketahuilah bahwa disk 30 GB itu tetap ditagih sekitar $1,2 per bulan per disk. Kalau tidak disengaja, periksa dengan gcloud compute disks list dan pastikan kolom users kosong untuk disk yang sudah tidak terpakai.

Menghapus VM lalu tidak memeriksa IP-nya. Setelah VM dihapus, IP statis yang tadinya menempel bisa tetap berstatus RESERVED. Jalankan ini setelah penghapusan:

Cloud Shell asisten
gcloud compute addresses list --format="table(name,status,users)"

Kalau masih ada baris yang muncul, hapus manual. Kalau tidak diperiksa, alamat menganggur itu menagih $0,01 per jam tanpa kamu sadari, dan itu satu-satunya pos yang benar-benar bisa membocorkan kredit setelah semuanya berhenti.

Bucket terhapus padahal masih berguna. Bucket berisi arsip geoserver-data dan .env, jadi pertahankan kalau kamu masih mungkin memakai project ini lagi. Ukurannya kecil dan biayanya praktis nol. Hapus hanya kalau project-nya memang akan ditinggalkan sepenuhnya:

Cloud Shell asisten
gcloud storage rm --recursive "gs://NAMA_BUCKET"

Alamat IP akan berbeda, jadi tiga hal ini perlu dikerjakan berurutan:

Cloud Shell asisten
gcloud compute instances start "$VM_NAME" --zone="$ZONE"
gcloud compute instances describe "$VM_NAME" \
--zone="$ZONE" \
--format='get(networkInterfaces[0].accessConfigs[0].natIP)'

Alamat yang muncul dipakai untuk memperbarui NEXTAUTH_URL pada .env, lalu perbarui record A di Cloudflare, lalu terbitkan ulang sertifikat dengan prosedur pada bagian 24. Ketiga container menyala sendiri karena memakai restart: unless-stopped, dan GeoServer butuh sekitar 47 detik sebelum siap melayani.

Jalankan ketiga perintah ini setelah pembersihan. Yang benar adalah ketiganya tidak menampilkan resource apa pun:

Cloud Shell asisten
gcloud compute instances list --format="table(name,status)"
gcloud compute addresses list --format="table(name,status,users)"
gcloud compute disks list --format="table(name,sizeGb,users)"

Terakhir, buka Billing Reports beberapa hari kemudian. Laporan itu tertinggal satu sampai dua hari, jadi tagihan dari resource yang sudah dihapus masih bisa muncul sesaat setelah pembersihan.