Skip to main content

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.

#CekExpectedActual 2026-06-11Status
1TLS cert SAN include api-merchant.kesles.commatchSeparate cert /etc/letsencrypt/live/api-merchant.kesles.com/
2GET /api/psp/v1/merchants (no auth)401 JSON dari dashboard_api401 JSON (verified loopback)
3POST /api/psp/v1/payment-events/transaction401 JSON401 JSON
4POST /api/partner/v1/auth/token400/401 JSON400/401 JSON
5GET /404404 (try_files $uri $uri/ =404)
6Port 80 redirect301 ke https://...301
7Security headersHSTS + X-Frame + CSP dllSemua 6 header terpasang (verified curl -sI)
8Rate limitinglimit_req per locationpsp_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 dari merchant_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_general zone) + client_max_body_size di-set per location; limit_req_zone didefinisikan di /etc/nginx/nginx.conf http block.

Loopback dari VM (running dashboard_api lokal pada 127.0.0.1:8082): curl http://127.0.0.1:8082/api/psp/v1/merchants401 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_pass patterns (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

Aspectkesles.com.conf (existing)api-merchant.kesles.com.conf (this runbook)
server_namekesles.com (+ www.kesles.com via www.kesles.com.conf)api-merchant.kesles.com
ConsumerBrowser users (mobile app, dashboard staff, public landing page)Backend integrators (PSP, partner bank, partner non-bank) — server-to-server
Content servedStatic files (landing page, Flutter dashboard SPA) + reverse proxyReverse 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 stylehttp://localhost:8080/; with trailing slash → strips prefix /merchant/api/ before forwardinghttp://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/securityCORS + dashboard session cookieHMAC-SHA256 + IP allowlist (handler middleware, nginx is not involved in auth)
Internet visibilityPublic for all end usersPublic, 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-alltry_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.com already configured at Cloudflare (proxied or DNS-only).
  • Backend services running on the VM:
    • dashboard_api listening on 127.0.0.1:8082 (PSP lookup + payment-events)
    • partner_service listening on 127.0.0.1:8086 (partner API)
    • integration_api listening on 127.0.0.1:8092 (integration / sandbox)
  • Existing certbot setup for kesles.com (the kesles.com-0001 cert 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 -d flags. --expand only 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

SymptomLikely causeFix
HTTP/2 404 text/plain "404 page not found"Vhost not loaded by nginxsudo nginx -T | grep "server_name api-merchant" — should list the new server block
502 Bad GatewayBackend service downjournalctl -u dashboard-api -n 50, restart if needed
SSL handshake error / unable to get local issuer certificateCert SAN does not include api-merchant.kesles.comRe-run step 2 with the right -d flags
404 JSON {"error":"not_found"...} from our origin (not Cloudflare)Path didn't match any location blockCheck the request path; this means catch-all caught it, route mapping is wrong
Still 404 page not found after nginx -t passesCloudflare cache on edgeBypass: 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:

  1. Smoke-test HMAC with the credential issued from the dashboard (Master Data → Partner → API Credentials).
  2. Hand over credential_key + plaintext credential_secret to integration partners via 1Password / Bitwarden (never plaintext channels).
  3. Update allowed_ip_ranges in partner.api_credentials once partners share their static egress IPs.

The infra team is not expected to touch credentials, secrets, or the database.

  • 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.