Runbook — Setup api-merchant.kesles.com Subdomain
Provision the public subdomain api-merchant.kesles.com so that external integrators (PSP, partner banks, partner non-banks) can reach Kesles Merchant integration APIs.
Deployment status (last re-probed 2026-06-11)
Status: ✅ LIVE & HARDENED — vhost, cert, proxy routing, security headers, dan rate limiting semua aktif.
| # | Cek | Expected | Actual 2026-06-11 | Status |
|---|---|---|---|---|
| 1 | TLS cert SAN include api-merchant.kesles.com | match | Separate cert /etc/letsencrypt/live/api-merchant.kesles.com/ | ✅ |
| 2 | GET /api/psp/v1/merchants (no auth) | 401 JSON dari dashboard_api | 401 JSON (verified loopback) | ✅ |
| 3 | POST /api/psp/v1/payment-events/transaction | 401 JSON | 401 JSON | ✅ |
| 4 | POST /api/partner/v1/auth/token | 400/401 JSON | 400/401 JSON | ✅ |
| 5 | GET / | 404 | 404 (try_files $uri $uri/ =404) | ✅ |
| 6 | Port 80 redirect | 301 ke https://... | 301 | ✅ |
| 7 | Security headers | HSTS + X-Frame + CSP dll | Semua 6 header terpasang (verified curl -sI) | ✅ |
| 8 | Rate limiting | limit_req per location | psp_hook zone + api_general zone aktif | ✅ |
Routing & hardening saat ini:
/api/psp/v1/→dashboard_api(:8082) — PSP lookup + payment-events (backend menerima path versioned penuh)./api/integration/v1/→integration_api(:8092) — PSP tester / sandbox./api/partner/v1/→partner_service(:8086) — partner API (sudah diekstrak darimerchant_core_api).- Security headers (HSTS, X-Frame-Options DENY, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, CSP
default-src 'none') terpasang di server block. limit_req(psp_hook+api_generalzone) +client_max_body_sizedi-set per location;limit_req_zonedidefinisikan di/etc/nginx/nginx.confhttp block.
Loopback dari VM (running dashboard_api lokal pada 127.0.0.1:8082): curl http://127.0.0.1:8082/api/psp/v1/merchants → 401 missing_auth_headers JSON — konfirmasi backend handler benar.
Why a separate subdomain
kesles.com already serves browser-facing traffic (landing page, Flutter dashboard SPA, mobile API). Mixing back-end-to-back-end integration paths (/api/psp/v1/*, /api/psp/v1/payment-events/*, /api/partner/v1/*) into the same vhost would:
- Conflate static-asset SPA fallback (
try_files ... /index.html) with API path matching — risk of integrator hitting a stale HTML file when a backend route is missing. - Mix CORS / browser-session middleware with HMAC-only middleware in one config.
- Mix two
proxy_passpatterns (with vs without trailing slash → strip vs preserve prefix). - Make audit & blast radius wider — any incident on the integration side forces touching the same file as the dashboard / mobile vhost.
So the architecture decision is: one vhost per consumer category.
vhost comparison
| Aspect | kesles.com.conf (existing) | api-merchant.kesles.com.conf (this runbook) |
|---|---|---|
server_name | kesles.com (+ www.kesles.com via www.kesles.com.conf) | api-merchant.kesles.com |
| Consumer | Browser users (mobile app, dashboard staff, public landing page) | Backend integrators (PSP, partner bank, partner non-bank) — server-to-server |
| Content served | Static files (landing page, Flutter dashboard SPA) + reverse proxy | Reverse proxy only — no static |
| Mounted paths | / → static /var/www/html/kesles_new /merchant/dashboard/ → static SPA /merchant/api/ → port 8080 (mobile API) /merchant/dashboard-api/ → port 8082 (dashboard API) | /api/psp/v1/* → port 8082 (lookup + payment-events nested) /api/integration/v1/* → port 8092 (integration_api) /api/partner/v1/* → port 8086 (partner_service) / → try_files ... =404 |
proxy_pass style | http://localhost:8080/; with trailing slash → strips prefix /merchant/api/ before forwarding | http://localhost:8082/; and http://localhost:8086/api/partner/v1/; → the backend receives the full versioned path the PSP/partner integrator sent (e.g. /api/psp/v1/merchants) |
| Auth/security | CORS + dashboard session cookie | HMAC-SHA256 + IP allowlist (handler middleware, nginx is not involved in auth) |
| Internet visibility | Public for all end users | Public, but only callers with a credential at partner.api_credentials / psp.api_keys can authenticate |
| SSL cert | /etc/letsencrypt/live/kesles.com-0001/ | Separate cert under /etc/letsencrypt/live/api-merchant.kesles.com/ (extending the kesles.com-0001 cert via --expand is an alternative path) |
| Catch-all | try_files $uri $uri/ /index.html (SPA fallback) | try_files $uri $uri/ =404 (must NOT serve HTML / landing) |
Prerequisites
- SSH access to the production VM (
ptikn3-vm) with sudo. - DNS for
api-merchant.kesles.comalready configured at Cloudflare (proxied or DNS-only). - Backend services running on the VM:
dashboard_apilistening on127.0.0.1:8082(PSP lookup + payment-events)partner_servicelistening on127.0.0.1:8086(partner API)integration_apilistening on127.0.0.1:8092(integration / sandbox)
- Existing certbot setup for
kesles.com(thekesles.com-0001cert is the source of truth for SAN extension).
Pre-flight checks
Before changing anything, confirm the backends are reachable on loopback (this is the test that proves the issue is purely the missing vhost):
# Should return 401 JSON (HMAC handler is alive but rejects unsigned request)
curl -i http://127.0.0.1:8082/api/psp/v1/merchants
# Expected:
# HTTP/1.1 401 Unauthorized
# Content-Type: application/json
# {"error":"missing_auth_headers", ...}
# Should also return 401 JSON (Partner bearer handler — empty body kena 400/401)
curl -i -X POST -H "Content-Type: application/json" -d '{}' \
http://127.0.0.1:8086/api/partner/v1/auth/token
# Should return 200 JSON
curl -i http://127.0.0.1:8086/health
If any of those fails, fix the backend first — there is no point setting up the proxy when the upstream is dead.
Confirm the public probe still 404s (proves the vhost is still missing):
curl -i https://api-merchant.kesles.com/api/psp/v1/merchants
# Expected (before this runbook is applied):
# HTTP/2 404
# content-type: text/plain
# 404 page not found
Steps
1. Inspect existing cert SAN
sudo openssl x509 -noout -ext subjectAltName \
-in /etc/letsencrypt/live/kesles.com-0001/fullchain.pem
Capture every domain listed in DNS:... — they all need to remain in the --expand invocation.
2. Extend the cert
Recommended path: a single cert covering all kesles.com domains plus the new subdomain.
sudo certbot certonly --nginx \
-d kesles.com \
-d www.kesles.com \
-d api-merchant.kesles.com \
--cert-name kesles.com-0001 \
--expand
Add every SAN from step 1 to the
-dflags.--expandonly adds; missing flags will silently drop coverage.
If Cloudflare proxy is on (orange cloud) and HTTP-01 challenge fails, choose one:
- Temporarily switch the subdomain to grey cloud at Cloudflare DNS, run certbot, then flip back to orange.
- Use DNS-01 challenge with a Cloudflare API token (
--dns-cloudflare).
Alternative: separate cert.
sudo certbot --nginx -d api-merchant.kesles.com
In that case, update the ssl_certificate* lines in step 3 to /etc/letsencrypt/live/api-merchant.kesles.com/.
3. Create the vhost
/etc/nginx/sites-available/api-merchant.kesles.com.conf:
server {
listen 443 ssl;
server_name api-merchant.kesles.com;
# Body size for KYC photos / bulk merchant payload
client_max_body_size 10M;
# ─── PSP integration v1 (HMAC-SHA256, dashboard_api) ────────────────
# Covers both: lookup `/api/psp/v1/merchants*` AND payment-events
# `/api/psp/v1/payment-events/*` (event receiver nested under PSP namespace).
location /api/psp/v1/ {
proxy_pass http://localhost:8082/; # dashboard_api — backend receives the full /api/psp/v1/... path
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# ─── Integration API v1 (PSP tester / sandbox, integration_api) ─────
location /api/integration/v1/ {
proxy_pass http://localhost:8092/api/integration/v1/;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# ─── Partner API v1 (Bearer/HMAC, partner_service) ──────────────────
location /api/partner/v1/ {
proxy_pass http://localhost:8086/api/partner/v1/;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# ─── Catch-all 404 (do NOT fall through to landing page) ────────────
location / {
try_files $uri $uri/ =404;
}
location ~ /\. {
deny all;
}
ssl_certificate /etc/letsencrypt/live/api-merchant.kesles.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api-merchant.kesles.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
# Port 80 redirect (in case Cloudflare forwards plain HTTP to origin)
server {
listen 80;
server_name api-merchant.kesles.com;
return 301 https://$host$request_uri;
}
4. Activate and reload
sudo ln -sf /etc/nginx/sites-available/api-merchant.kesles.com.conf \
/etc/nginx/sites-enabled/
sudo nginx -t # must pass
sudo systemctl reload nginx # ~1s, no downtime
5. Verify
# From the VM (no Cloudflare cache)
curl -i https://api-merchant.kesles.com/api/psp/v1/merchants
Expected:
HTTP/2 401
content-type: application/json
{"error":"missing_auth_headers","message":"pspintegration: missing one or more required auth headers","request_id":""}
If the response is still 404 page not found (text/plain, 19 bytes), the request was answered by Cloudflare or by the nginx default vhost — it never reached the new server block. See troubleshooting.
Also probe the other paths to confirm fan-out works:
# Event receiver (HMAC required, nested under /api/psp/v1/) → same 401 JSON shape
curl -i -X POST https://api-merchant.kesles.com/api/psp/v1/payment-events/transaction
# Partner auth token (no HMAC, but expects credentials) → 400/401 JSON, NOT 404 text
curl -i -X POST -H "Content-Type: application/json" -d '{}' \
https://api-merchant.kesles.com/api/partner/v1/auth/token
Confirm legacy paths return 404 from our origin (proves backend rename took effect):
# Both should return 404 from dashboard_api / merchant_core_api
# (NOT text/plain "Cannot ..." Express 404 from neighbour vhost)
curl -i https://api-merchant.kesles.com/api/psp/merchants
curl -i -X POST https://api-merchant.kesles.com/partner/v1/auth/token
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
HTTP/2 404 text/plain "404 page not found" | Vhost not loaded by nginx | sudo nginx -T | grep "server_name api-merchant" — should list the new server block |
502 Bad Gateway | Backend service down | journalctl -u dashboard-api -n 50, restart if needed |
SSL handshake error / unable to get local issuer certificate | Cert SAN does not include api-merchant.kesles.com | Re-run step 2 with the right -d flags |
404 JSON {"error":"not_found"...} from our origin (not Cloudflare) | Path didn't match any location block | Check the request path; this means catch-all caught it, route mapping is wrong |
Still 404 page not found after nginx -t passes | Cloudflare cache on edge | Bypass: curl -i https://api-merchant.kesles.com/api/psp/v1/merchants --resolve api-merchant.kesles.com:443:127.0.0.1 -k |
Rollback
If the new vhost causes any issue, disable it without touching anything else:
sudo rm /etc/nginx/sites-enabled/api-merchant.kesles.com.conf
sudo nginx -t && sudo systemctl reload nginx
The cert extension is non-destructive (other domains keep working), no rollback needed there.
Post-deploy handover
Once the public probe returns 401 JSON, the subdomain is ready for the engineering team to:
- Smoke-test HMAC with the credential issued from the dashboard (Master Data → Partner → API Credentials).
- Hand over
credential_key+ plaintextcredential_secretto integration partners via 1Password / Bitwarden (never plaintext channels). - Update
allowed_ip_rangesinpartner.api_credentialsonce partners share their static egress IPs.
The infra team is not expected to touch credentials, secrets, or the database.
Related runbooks
- Deploy Dashboard API — how the upstream binary on port 8082 gets refreshed.
- Master Key Rotation — how the AES master key (used to decrypt HMAC secrets) is rotated.
- Secret Rotation — partner credential rotation lifecycle.
Related plans
- API Versioning v1 Migration Plan — decision log untuk path rename
/api/psp/v1/*+/api/partner/v1/*. Phase 2c (runbook ini) adalah deploy step setelah Phase 2a (PSP rename) + Phase 2b (Partner rename) di backend selesai.