Rencana yang saya catat sebelum mencabut token baca Sanity yang lama punya satu syarat, dan untuk staging syarat itu saya tulis dua kali: token lama baru boleh dicabut setelah dasbor di staging dan di produksi sama-sama menunjukkan sidik jari token yang baru. Alasannya jelas. Sanity menolak request yang membawa token yang sudah dicabut dan tidak jatuh ke akses anonim, jadi pencabutan yang terlalu dini memutus semua pembacaan Sanity.
Penggantinya token Viewer yang saya buat dan uji sendiri, dan setelah rilis 29 September pukul 06.45 UTC, dasbor produksi menunjukkan sidik jarinya untuk runtime maupun build. Sidik jari staging tidak terbaca, karena API dasbor staging menjawab 401. Staging saya anggap sama dengan produksi, dan pukul 07.00 UTC token lama saya cabut.
Produksi tetap normal: 13 path menjawab 200, sitemap berisi 546 artikel dan 869 istilah glosarium, dan sidik jari runtime serta build-nya menunjuk token baru. Sitemap staging mendadak jadi urlset kosong, 0 dan 0, padahal pukul 06.47 UTC masih 546 dan 869. Staging ternyata masih memakai token lama, dan API dasbor staging memang tidak bisa dibaca dengan token yang ada untuk memeriksanya.
Gagal tanpa bunyi
Yang membuat kasus ini licin, tidak ada yang gagal dengan keras. Dataset Sanity di proyek ini privat, dan dataset privat tidak menjawab query anonim dengan error, melainkan dengan 200 berisi hasil kosong. Query hitungan dokumen glosarium tanpa token keluar 0, baik lewat apicdn maupun api, sementara token Viewer mendapat 869. Token yang hilang atau dicabut gagal diam-diam sebagai data kosong, bukan 5xx.
Penolakan token lama sebetulnya tercatat di log pm2 staging yang ditunjukkan DevOps klien: [sanity] fetch failed: Unauthorized - Session not found. Dari luar, sitemap staging cuma tampil kosong, tanpa error dan tanpa alarm, sementara listing-nya masih terisi dari Data Cache Next.js. Jadi yang perlu diperiksa adalah isinya, yaitu jumlah <loc> di sitemap dan tautan di listing, bukan sekadar kode status.
Build ulang, lalu pm2
Build staging yang sedang berjalan lebih tua dari pergantian token, jadi atas permintaan saya sebuah commit kosong didorong ke develop untuk membangun ulang staging. Staging terbangun ulang pukul 07.15 UTC dan sitemap-nya tetap kosong, sementara satu halaman istilah glosarium merender not-found di staging padahal normal di produksi.
DevOps klien lalu menunjukkan bahwa CodeBuild dan .env di server staging sama-sama sudah berisi token Viewer baru, sementara log pm2 tetap menulis Unauthorized. Diagnosis pertama saya, masih di hari yang sama, menyalahkan pm2: prosesnya masih membawa token lama di env-nya sendiri, karena pm2 menyimpan env yang dipakai saat proses dimulai, dan pemuatan .env oleh Next tidak pernah menimpa variabel yang sudah diset. Saat itu saya mencatatnya sebagai akar masalah.
DevOps klien menjalankan perbaikannya pukul 10.02 UTC. Nama folder dan aplikasinya saya samarkan:
cd ~/app-staging && set -a && . ./.env && set +a && pm2 restart app-staging --update-env && pm2 save --forceSetelah itu halaman istilah glosarium di staging tampil lagi. Sitemap-nya masih kosong pada saat itu, tapi hanya karena rute sitemap ISR (revalidate 3600, x-nextjs-cache: HIT) menyimpan versi kosong dari jendela yang rusak. Catatan serah terima di akhir sesi itu menyebut staging rusak dari 07.00 sampai 10.02 UTC, jadi tiga jam dua menit, dan sudah pulih penuh dengan sitemap 548 dan 869.
Pelajaran pm2 itu tetap benar: pm2 restart --update-env hanya menyalin env dari shell yang memanggilnya, jadi nilai .env yang berubah baru sampai ke aplikasi setelah shell men-source .env lebih dulu. Hanya saja itu bukan akar masalahnya, dan saya baru tahu dua hari kemudian.
Setiap build lahir kosong
Malam itu polanya mulai kelihatan. Tepat setelah setiap build staging, sitemap dan indeks pencarian kosong, keduanya hasil prerender saat build dengan x-nextjs-cache: HIT, sementara halaman yang dirender saat runtime membaca Sanity dengan benar, dan keadaan itu pulih sendiri dalam jendela ISR 3600 detik. Dalam satu perbandingan tepat setelah build, pukul 13.06 UTC, staging menyajikan sitemap 0/0 dan indeks pencarian 14 byte, sedangkan produksi 549/869 dan 263.572 byte.
Komentar di next.config.ts proyek ini sudah menjelaskan mekanismenya. Halaman yang di-prerender membaca Sanity pada saat build, jadi build yang masih memegang token yang salah atau sudah dicabut mengirim halaman itu kosong. Karena itu konfigurasinya meng-inline sidik jari token yang dipakai build, yaitu delapan karakter hex pertama dari SHA-256-nya, atau string kosong kalau tidak ada token, dan tidak pernah tokennya sendiri. Versi yang disederhanakan, bukan kutipan berkasnya:
// disederhanakan; nama variabel lokal netral
import { createHash } from "node:crypto";
const raw = process.env.SANITY_API_READ_TOKEN; // nilai mentah, tanpa trim
const fingerprint = raw
? createHash("sha256").update(raw).digest("hex").slice(0, 8)
: "";
// yang di-inline ke build hanya `fingerprint`, tidak pernah `raw`Dasbor tadi menampilkan sidik jari semacam ini untuk runtime dan untuk build, tapi belakangan ketahuan dasbor itu memang tidak ada di staging. Halamannya menjawab 404, yang hanya terjadi kalau variabel token debug-nya tidak diset di lingkungan itu, dan API-nya ikut menjawab 401.
Satu grep di server
Jadi sidik jari build staging harus dibaca langsung dari keluaran build di server. Nilainya tinggal di berkas JavaScript di bawah .next/server, dan satu grep cukup untuk mencetaknya. Cara ini saya verifikasi dulu di lokal sebelum keluarannya saya minta dari DevOps klien. Nama field-nya saya samarkan:
grep -rhoE --include='*.js' 'tokenFingerprintAtBuild:[^,}]{0,30}' .next/server | sort -uDevOps klien menjalankannya di server staging pada 30 September malam, dan pada 1 Oktober kesimpulannya tercatat: sidik jari build staging berbeda dari sidik jari token Viewer di runtime dan build produksi. Karena next.config meng-hash nilai env mentah tanpa trim, saya uji di lokal varian token Viewer satu per satu: newline di akhir, CRLF, spasi, tanda kutip, awalan Bearer, satu baris env utuh, kurang satu karakter. Tidak satu pun menghasilkan sidik jari staging itu, begitu juga variabel mana pun di .env.local lokal saya.
Sisi repo terbukti bersih: tidak ada berkas env yang dilacak git, tidak ada buildspec atau skrip deploy di repo, dan SANITY_API_READ_TOKEN hanya dibaca, tidak pernah di-assign. Pohon kode yang sama, dibangun berselang 8 menit untuk produksi dan untuk staging, menghasilkan sidik jari berbeda, jadi nilainya datang dari lingkungan build staging, bukan dari kode.
Dugaan saya hari itu, build staging kemungkinan besar memegang token lama dari sumber yang tidak terlihat di konsol DevOps klien, entah entri SSM atau Secrets Manager, override di CodePipeline, atau satu langkah di buildspec. DevOps klien menolaknya dengan jawaban pendek, "env var is changed, nowhere to update". Daftar dugaan berikutnya ikut memuat .env.local, .env.production, dan .env.production.local, yang didahulukan Next di atas .env kalau build berjalan di server.
Dua berkas di satu folder
Akar masalahnya ditemukan DevOps klien pada 1 Oktober. Menurut temuan mereka, folder aplikasi staging di server berisi dua berkas env: .env dengan token Viewer baru, dan .env.local sisa setup awal staging, cuma 7 variabel, yang masih memegang token lama. Di produksi, menurut temuan yang sama, hanya ada .env.
Next.js memuat .env.local sebelum .env, jadi build staging yang berjalan di server mengambil token lama. Urutannya tertulis di @next/env 16.3.6 yang terpasang di repo, berkas satu baris minified yang saya potong di bagian yang relevan:
// @next/env 16.3.6, dist/index.js (minified, dipotong)
if(!a){a=Object.assign({},process.env)}
// ...
const p=Object.assign({},a)
// ...
if(typeof u[t]==="undefined"&&typeof p[t]==="undefined"){u[t]=(l=e.parsed)===null||l===void 0?void 0:l[t]}
// ...
const d=c?"test":n?"development":"production"
const f=[`.env.${d}.local`,d!=="test"&&`.env.local`,`.env.${d}`,".env"].filter(Boolean)Pada mode produksi, daftar berkasnya .env.production.local, .env.local, .env.production, lalu .env, dan nilai dari sebuah berkas hanya dipakai kalau variabel itu belum ada di salinan env proses yang diambil di awal dan belum diisi berkas sebelumnya. Artinya env proses mengalahkan semua berkas, dan di antara berkas, yang paling atas di daftar yang menang. Ini jebakan yang berbeda dari NEXT_PUBLIC_* yang dibakar ke bundle klien saat build, yang pernah saya tulis terpisah. SANITY_API_READ_TOKEN tidak berawalan NEXT_PUBLIC_ dan tokennya tidak pernah di-inline, jadi yang salah di sini adalah berkas mana yang menang.
Dua aturan itu juga menjelaskan kenapa diagnosis pm2 terasa benar. Koreksi yang saya catat pada 1 Oktober: penyebab sebenarnya .env.local sisa, yang dibaca build dan juga runtime setiap kali env proses tidak memuat variabel itu, sedangkan catatan pm2 tetap benar tapi bukan akarnya. Perintah set -a; . ./.env pada 29 September kemungkinan besar berhasil justru karena env proses mengalahkan semua berkas, termasuk berkas sisa itu.
Selama dua hari, dari 29 September sampai 1 Oktober, setiap build staging memakai token yang sudah dicabut, sementara aplikasi yang berjalan tampak sehat karena env prosesnya memegang token baru. Berkas itu juga tidak datang dari repo. .gitignore baris 34 berisi .env* dan tidak ada berkas env yang pernah di-commit di branch mana pun, jadi .env.local di staging dibuat tangan langsung di server.
Bukti dari build berikutnya
DevOps klien melaporkan sudah menghapus .env.local di staging dan me-restart pm2 tanpa build ulang. Build yang berjalan saat itu masih build 29 September dengan sidik jari lama, jadi buktinya harus menunggu build staging berikutnya. Build itu berjalan pada 2026-10-01T08:43:36Z, dan bacaan pertama 80 detik kemudian sudah menyajikan sitemap prerender yang penuh dan indeks pencarian 240.094 byte, padahal setiap build sejak 29 September lahir kosong. Produksi, yang dibangun tujuh menit 23 detik kemudian pada 2026-10-01T08:50:59Z, menyajikan jumlah entri sitemap dan ukuran indeks yang sama persis.
Penjaga di build dan start
Menghapus berkasnya menutup kasus ini, tapi tidak menutup kelasnya. Penjaga yang saya pasang hari itu juga mendaftar berkas .env* di folder aplikasi saat next build dan saat next start, karena next.config.ts dibaca di keduanya, jadi peringatannya masuk ke log build dan log server di setiap boot, dan laporannya ikut terbawa ke pesan deploy di Telegram. Helper-nya selalu menulis satu baris netral, dan peringatan hanya muncul kalau dua berkas menyetel variabel yang sama dengan nilai berbeda. Dua potongan teksnya, dikutip apa adanya:
const line = `env files at build: ${report.files.length > 0 ? report.files.join(", ") : "none"}`;
// ...
`Warning: ${names} ${verb} different values in ${report.files.join(" and ")}. ` +
"Next.js uses the first file in that list that sets a variable, so keep a single env file on each server.",Peringatan itu hanya menyebut nama variabel. Simulasi di folder sementara, dengan .env.local berisi token lama dan .env berisi token baru, mencetak peringatan yang hanya menyebut SANITY_API_READ_TOKEN tanpa nilai apa pun. Penjaganya hidup di produksi pada 1 Oktober pukul 08.52 UTC tanpa perubahan keluaran yang terlihat, dan baris di pesan deploy produksi berbunyi env files at build: .env.
Dua hal belum terbukti. Peringatan penjaga baru teruji lewat simulasi lokal, karena berkas sisa di staging sudah dihapus sebelum penjaganya dirilis, jadi belum ada catatan peringatan itu muncul di server sungguhan. Lalu satu pertanyaan soal produksi tidak pernah saya kirim. Dugaan saya, proses pm2 produksi tidak menyimpan SANITY_API_READ_TOKEN di env-nya sendiri, jadi Next mengisinya dari .env setiap start. Perintah pm2 env <id> | grep -c SANITY_API_READ_TOKEN di server produksi akan menjawabnya: 0 berarti aman, 1 berarti jebakan yang sama menunggu pergantian token berikutnya.
Cetak sidik jarinya di tempat nilai dipakai
Log build yang dibaca pada 7 Oktober belakangan memperlihatkan jarak antara tempat nilai diset dan tempat nilai dipakai. CodeBuild menjalankan pnpm build hanya sebagai cek dan buildspec-nya menulis .env dari variabel CodeBuild, lalu skrip deploy di server menjalankan pnpm build sekali lagi dan pm2 restart --update-env, jadi build yang melayani pengunjung adalah build server. Jawaban "env var is changed, nowhere to update" benar untuk tempat yang dilihat DevOps klien, karena CodeBuild dan .env memang sudah berisi token baru. Yang tidak terlihat dari sana adalah .env.local di folder tempat build itu berjalan.
Aturan yang saya catat dari kasus ini: minta sidik jari yang dicetak di tempat nilai dipakai, bukan bukti dari tempat nilai diset, karena tempat nilai diset tidak bisa menunjukkan sumber mana yang menang. Kalau perubahan env tidak masuk, daftar semua berkas .env* di folder aplikasi dan bandingkan sidik jarinya, jangan cuma .env. Dan selama token bisa hilang tanpa error, periksa isi, bukan kode status.
Pelajaran yang paling mahal justru yang paling pendek. Staging tidak bisa dibaca, lalu diasumsikan sama dengan produksi, dan pencabutan token lama mengosongkannya.