Frontend — GCS + Cloudflare CDN¶
Hosting SPA (Single Page Application) za $0/mies. GCS bucket jako origin, Cloudflare jako CDN, SSL i DDoS protection — bez Google Load Balancera.
Ewolucja architektury frontendu¶
Etap 1 (ADR-003): GCS bucket bezpośrednio
URL: https://storage.googleapis.com/frontend-bucket/index.html
✓ $0 koszt
✗ brak custom domeny, brak HTTPS na własnej domenie
Etap 2 (ADR-005): Cloudflare CDN jako proxy przed GCS
URL: https://app.kamilos.xyz
✓ custom domena z HTTPS
✓ CDN (PoP Warszawa/Frankfurt)
✓ DDoS protection L3/L4/L7
✓ WAF (managed rules)
✓ $0/mies (Cloudflare Free tier)
✗ Bucket musi się nazywać dokładnie jak domena (CNAME trick)
Cloudflare CNAME trick — dlaczego bucket musi mieć nazwę domeny¶
GCS obsługuje custom domeny przez CNAME do c.storage.googleapis.com. Gdy request przychodzi na app.kamilos.xyz, GCS szuka bucketa o nazwie app.kamilos.xyz.
Browser → app.kamilos.xyz
│ DNS
▼
Cloudflare Edge (proxy ON — orange cloud)
│ origin request to storage.googleapis.com
│ Host: app.kamilos.xyz
▼
GCS → szuka bucketa "app.kamilos.xyz"
→ serwuje pliki z tego bucketa
Bez Cloudflare: CNAME app → c.storage.googleapis.com — HTTP tylko (GCS nie terminuje SSL na custom domenach). Z Cloudflare proxy: SSL terminowany na edge Cloudflare, GCS dostaje HTTP od Cloudflare.
Terraform — warstwa tf/frontend/¶
# tf/frontend/main.tf
resource "google_storage_bucket" "frontend" {
name = "app.kamilos.xyz" # (1)
project = var.project_id
location = "EU" # (2)
uniform_bucket_level_access = true
public_access_prevention = "unspecified" # (3)
force_destroy = true
website {
main_page_suffix = "index.html"
not_found_page = "index.html" # (4)
}
}
resource "google_storage_bucket_iam_member" "public_read" {
bucket = google_storage_bucket.frontend.name
role = "roles/storage.objectViewer"
member = "allUsers" # (5)
}
name = "app.kamilos.xyz"— nazwa bucketa = domena. Wymagane przez CNAME tricklocation = "EU"— multi-region EU, tańszy niż single-regioneurope-central2, szybszy dla użytkowników w Europiepublic_access_prevention = "unspecified"— musi byćunspecified(nieenforced) żebyallUsers objectViewerzadziałało. Dla bucketów backendowych (stan TF):enforcednot_found_page = "index.html"— SPA routing.GET /app/profile→ plik nie istnieje → GCS zwracaindex.htmlz HTTP 404. JS router przejmuje → wyświetla właściwy widok. Uwaga: HTTP 404 zamiast 200 dla deep links — Cloudflare może cachować 404. Rozwiązanie: Page Rule lub Transform Rule w Cloudflare (redirect 404 → 200)allUsers objectViewer— publiczny dostęp do czytania obiektów (pobieranie plików). Nie daje uprawnień do zarządzania bucketem
Konfiguracja Cloudflare DNS¶
W panelu Cloudflare dla domeny kamilos.xyz:
Type Name Content Proxy status
CNAME app c.storage.googleapis.com Proxied (orange cloud) ✓
CNAME staging c.storage.googleapis.com Proxied (orange cloud) ✓
Proxy ON (orange) — obowiązkowo dla HTTPS
Przy proxy OFF (grey cloud): CNAME wskazuje bezpośrednio na GCS, który nie ma certyfikatu dla app.kamilos.xyz → przeglądarka pokazuje "not secure". Z proxy ON: Cloudflare terminuje SSL, GCS dostaje HTTP od Cloudflare edge.
Deploy plików¶
Ręczny deploy¶
# Prod
gcloud storage cp -r ./dist/* gs://app.kamilos.xyz/
# Ze strategią cache-control
gcloud storage cp frontend/index.html gs://app.kamilos.xyz/ \
--cache-control="no-cache, no-store, must-revalidate"
gcloud storage cp frontend/app.js gs://app.kamilos.xyz/ \
--cache-control="public, max-age=3600"
Auto-deploy przez GitHub Actions¶
# deploy-frontend.yml — fragment (prod)
- name: Deploy to prod (master)
if: github.ref_name == 'master'
run: |
gcloud storage cp frontend/app.js \
gs://app.kamilos.xyz/app.js \
--cache-control="public, max-age=3600"
gcloud storage cp frontend/index.html \
gs://app.kamilos.xyz/index.html \
--cache-control="no-cache, no-store, must-revalidate"
index.html z no-cache — przeglądarka zawsze pobiera świeżą wersję, która zawiera <script src="app.js?v=XXX">. JS i CSS z max-age=3600 — 1h cache na edge i w przeglądarce.
Aplikacja SPA — frontend/app.js¶
// Konfiguracja Firebase (apiKey jest publiczny)
const FIREBASE_CONFIG = {
apiKey: 'AIzaSyDB9HH...',
authDomain: 'gcp-prototype-1-20260224.firebaseapp.com',
projectId: 'gcp-prototype-1-20260224',
};
// URL API Gateway — hardkodowany dla prod
// Dla staging: nadpisywany przez `sed` w deploy-frontend.yml
const API_URL = 'https://gpc-gateway-31x6ik0l.ew.gateway.dev';
// Po zalogowaniu: zapisz logowanie + pobierz historię
async function recordAndShowLogins() {
const token = await auth.currentUser.getIdToken();
const headers = { Authorization: `Bearer ${token}` };
await fetch(`${API_URL}/api`, { method: 'POST', headers });
const res = await fetch(`${API_URL}/api`, { headers });
const data = await res.json();
renderLogins(data.last_logins ?? []);
}
Pułapki¶
Cloudflare cache — po deployu wymagany hard refresh
app.js jest cachowany przez 1h na edge Cloudflare. Po deployowaniu nowej wersji: Cmd+Shift+R (hard refresh) lub poczekaj 1h. Dla automatycznego inwalidowania: Cloudflare Cache Purge przez API lub Page Rule z Cache-Control: no-store dla index.html (który ładuje app.js z nowym hash/version).
GCS nie serwuje HTTPS na custom domenach bezpośrednio
GCS ma certyfikat dla *.storage.googleapis.com, nie dla app.kamilos.xyz. Cloudflare proxy jest wymagany dla HTTPS na custom domenie. Alternatywa bez Cloudflare: Google Load Balancer z SSL certificate resource (~$23/mies).
SPA routing a HTTP 404
not_found_page = "index.html" zwraca status 404 z treścią index.html. Większość SPA routerów działa z tym poprawnie. Jeśli Cloudflare Page Cache cachuje 404 błędnie — dodaj Cloudflare Page Rule: app.kamilos.xyz/* → Cache Level: Bypass.
Opcjonalna warstwa: Google LB + CDN + Cloud Armor¶
Projekt zawiera warstwę tf/frontend-lb/ do nauki Google Global Load Balancer bez zastępowania Cloudflare. Tworzy niezależny endpoint app-lb.kamilos.xyz (~$23-28/mies).
Pełna dokumentacja tej warstwy: Rozdział 14 — (Opcjonalna) LB + CDN + Cloud Armor.
Skrót architektury:
Browser → app-lb.kamilos.xyz
│ DNS A record → 34.50.151.193 (static global IP)
│ proxy OFF — grey cloud w Cloudflare!
▼
Cloud Armor Edge (geo/IP filtering)
▼
Global HTTPS Load Balancer (EXTERNAL_MANAGED)
▼
Cloud CDN (USE_ORIGIN_HEADERS)
▼
GCS bucket: app-lb.kamilos.xyz
# Kluczowe zasoby (w kolejności zależności)
google_compute_global_address # static IP
google_compute_security_policy # Cloud Armor Edge
google_storage_bucket # GCS origin
google_compute_backend_bucket # łączy GCS + CDN + Cloud Armor
google_compute_url_map # routing → backend bucket
google_compute_managed_ssl_certificate # auto-provisionowany przez Google
google_compute_target_https_proxy # SSL termination
google_compute_global_forwarding_rule # public IP:443 → proxy
# + HTTP→HTTPS redirect (osobny url_map + http_proxy + forwarding_rule)
Cloud Armor Edge NIE obsługuje rate limiting
CLOUD_ARMOR_EDGE (jedyny typ obsługiwany przez backend_bucket) obsługuje tylko geoblocking i IP filtering. Rate limiting wymaga CLOUD_ARMOR (standard) + backend_service. Dla WAF + rate limiting przy Cloud Run: Google LB → Cloud Run przez backend_service.
DNS A record — grey cloud obowiązkowo
LB sam terminuje SSL (managed cert). Z Cloudflare proxy (orange cloud): dwa SSL handshaki, cert Google nigdy nie zostanie wydany. app-lb.kamilos.xyz musi wskazywać bezpośrednio na IP LB.
# Po terraform apply: dodaj A record i poczekaj na SSL
gcloud compute ssl-certificates describe frontend-lb-ssl \
--project=PROJECT_ID \
--format="value(managed.status)"
# PROVISIONING → ACTIVE (~10-30 min po DNS propagacji)
# Deploy frontendu do nowego bucketu
gcloud storage cp frontend/index.html gs://app-lb.kamilos.xyz/ \
--cache-control="no-cache, no-store, must-revalidate"
gcloud storage cp frontend/app.js gs://app-lb.kamilos.xyz/ \
--cache-control="public, max-age=3600"
# Usunięcie całej warstwy
cd tf/frontend-lb && terraform destroy -var project_id=PROJECT_ID