---
name: web-deployment
description: Deploy static web apps and HTML files to the server (43.157.206.165) with Caddy reverse proxy, served at custom subdomains under edisantosa.com or Duck DNS.
triggers:
  - deploy
  - subdomain
  - web server
  - dosis.edisantosa.com
  - duckdns
  - hosting
category: infrastructure
---

# Web Deployment Skill

Deploy static web apps (HTML, JS, single-page apps) to subdomains using Caddy on server `43.157.206.165`.

## Server Info

| Item | Value |
|------|-------|
| Public IP | `43.157.206.165` |
| Web root | `/home/ubuntu/kantor` |
| HTTP port | `80` |
| HTTPS port | `443` |
| Web server | Caddy v2.11.4 |
| Caddy config | `/etc/caddy/Caddyfile` |
| Cloudflare | `edisantosa.com` is Cloudflare-proxied — see DNS options below |

## Deployment Checklist

### 1. Copy file to web root

```bash
sudo cp /path/to/file.html /home/ubuntu/kantor/APP_DIR/
mkdir -p /home/ubuntu/kantor/APP_DIR
```

### 2. Update Caddyfile

> **⚠️ Edit via `sudo tee`, NOT `patch` or `write_file`**: The patch/write_file tools refuse to write to `/etc/caddy/Caddyfile` (sensitive system path). Use `sudo tee` instead:
>
> ```bash
> sudo tee /etc/caddy/Caddyfile > /dev/null << 'EOF'
> ...content...
> EOF
> ```

**Pattern for static SPA (single-page app, e.g. React/Vue/Supabase-connected app):**

```bash
sudo tee /etc/caddy/Caddyfile > /dev/null << 'EOF'
hermesedi.duckdns.org {
    reverse_proxy localhost:8081
}

dosishewan.duckdns.org {
    root * /home/ubuntu/kantor
    encode gzip
    file_server {
        precompressed
    }
    try_files {path} /kalkulator-volume-obat.html
    file_server
}

SUBDOMAIN.edisantosa.com {
    root * /home/ubuntu/kantor/APP_DIR
    encode gzip
    file_server
}

:80 {
    root * /home/ubuntu/kantor
    encode gzip
    file_server {
        precompressed
    }
    handle {
        try_files {path} {path}/ /index.html
        file_server
    }
}
EOF
```

- **Static HTML app** → use `file_server` (no backend server needed)
- **SPA connected to Supabase/Firebase** → use `file_server` (all logic client-side)
- **Backend server (Python/Node)** → use `reverse_proxy localhost:PORT`

### 3. Restart Caddy

```bash
# Kill old Caddy process first
ps aux | grep "caddy run" | grep -v grep | awk '{print $2}' | xargs -r kill

# Start fresh
sudo caddy run --config /etc/caddy/Caddyfile &
sleep 3
```

> **Note:** Caddy runs as foreground `caddy run` (not systemd). It auto-obtains SSL certificates from Let's Encrypt once DNS resolves. It retries automatically every 60s if the first attempt failed. SSL certs are domain-specific — a new subdomain needs its DNS A record pointing to `43.157.206.165` before Caddy can obtain the cert.

### 4. Verify

```bash
curl -s --connect-timeout 8 https://SUBDOMAIN.edisantosa.com -o /dev/null -w "%{http_code}"
```

## App: Sistem RRA (Supabase-connected SPA)
- **URL**: `https://rra.edisantosa.com`
- **App dir**: `/home/ubuntu/kantor/rra-app/`
- **Code**: `index.html` — single-file SPA (vanilla JS, NO Supabase SDK — uses raw `fetch` via proxy instead)
- **Auth**: Supabase Auth (email/password)
- **Database**: Project `mcgqjqkofsvtqcrzelup`
- **Proxy**: Node.js at `/home/ubuntu/kantor/rra-app/proxy.js` (port 3000)
- **DNS required**: A record `rra` → `43.157.206.165` at edisantosa.com DNS provider (Cloudflare DNS-only, grey cloud)
- **Supabase schema**: run via Supabase Dashboard → SQL Editor

### ⚠️ Supabase URL — VERIFY BEFORE USE

**Memory may contain wrong ref.** Always verify from the Supabase Dashboard:

1. Login to https://supabase.com/dashboard
2. Select project → **Settings** → **API**
3. Copy the **Project URL** field — format: `https://xxxxxxxxxxxx.supabase.co`
4. If `nslookup xxxxx.supabase.co 8.8.8.8` returns `NXDOMAIN`, the URL is wrong or project was deleted

**Current verified URL**: `https://mcgqjqkofsvtqcrzelup.supabase.co`

### Network-Blocked Supabase — Full Proxy Architecture

If the user's browser **cannot reach `supabase.co`** directly (DNS block, corporate firewall, ISP-level blocking), the Supabase JS SDK fails silently with `Failed to fetch`. The solution is a Node.js proxy on the VPS.

**Architecture:**
```
Browser → Caddy (HTTPS) → /api/* → Node proxy (port 3000) → Supabase (HTTPS)
```

**Working `proxy.js`** (save at `/home/ubuntu/kantor/rra-app/proxy.js`):
```js
const http = require('http');
const https = require('https');

const SB_URL = 'mcgqjqkofsvtqcrzelup.supabase.co';
const SB_KEY = 'sb_publishable_LDervgNHZ88W3a6XrFETOA_gdDXvDPM';

const server = http.createServer((req, res) => {
  // Handle CORS preflight
  if (req.method === 'OPTIONS') {
    res.writeHead(204, {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'apikey, Authorization, Content-Type, X-Client-Info'
    });
    res.end();
    return;
  }

  const proxyUrl = req.url.replace(/^\/api/, ''); // strip /api prefix
  const url = 'https://' + SB_URL + proxyUrl;

  const options = {
    headers: {
      'apikey': SB_KEY,
      'Authorization': 'Bearer ' + SB_KEY,
      'Content-Type': 'application/json'
    }
  };

  let body = [];
  req.on('data', chunk => body.push(chunk));
  req.on('end', () => {
    const postData = Buffer.concat(body);

    const proxyReq = https.request(url, {...options, method: req.method}, (proxyRes) => {
      res.writeHead(proxyRes.statusCode, {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'apikey, Authorization, Content-Type, X-Client-Info'
      });
      proxyRes.pipe(res, {end: true});
    });

    proxyReq.on('error', (e) => {
      res.writeHead(502);
      res.end(JSON.stringify({error: e.message}));
    });

    if (postData.length > 0) proxyReq.write(postData);
    proxyReq.end();
  });
});

server.listen(3000, '0.0.0.0', () => {
  console.log('Proxy running on port 3000');
});
```

**Caddyfile** must route `/api/*` to the proxy:
```
rra.edisantosa.com {
    root * /home/ubuntu/kantor/rra-app
    encode gzip
    file_server

    handle /api/* {
        reverse_proxy localhost:3000
    }
}
```

**App must use raw `fetch`, NOT Supabase SDK** — the SDK always calls `supabase.co` directly and bypasses any base URL trick. Replace all SDK calls with `fetch('/api/...')` calls through the proxy.

**App auth implementation** (no SDK):
```js
const API = '/api';
const SB_KEY = 'sb_publishable_LDervgNHZ88W3a6XrFETOA_gdDXvDPM';
const HEADERS = {'apikey': SB_KEY, 'Authorization': 'Bearer ' + SB_KEY, 'Content-Type': 'application/json'};

async function apiFetch(url, opts = {}) {
  const res = await fetch(API + url, {...opts, headers: {...HEADERS, ...(opts.headers||{})}});
  const text = await res.text();
  let json;
  try { json = JSON.parse(text); } catch { json = {error: text}; }
  if (!res.ok) throw json;
  return json;
}

// Login
const data = await apiFetch('/auth/v1/token?grant_type=password', {
  method: 'POST',
  body: JSON.stringify({email, password})
});

// Signup
const data = await apiFetch('/auth/v1/signup', {
  method: 'POST',
  body: JSON.stringify({email, password, options:{data:{full_name:name, instansi:inst}}})
});

// Insert RRA doc
await apiFetch('/rest/v1/rra_documents', {
  method: 'POST',
  body: JSON.stringify({...payload, user_id: currentUser.id})
});

// List RRA docs
const data = await apiFetch('/rest/v1/rra_documents?select=*&order=created_at.desc');
```

**Pitfalls:**
- VPS must be able to resolve `mcgqjqkofsvtqcrzelup.supabase.co` — test with `nslookup mcgqjqkofsvtqcrzelup.supabase.co 8.8.8.8`. If VPS can't resolve, the Supabase URL is wrong.
- Cloudflare proxy must be **DNS-only** (grey cloud) for the subdomain, otherwise Caddy ACME challenge fails.
- Rate limit on signup: Supabase limits signup emails. Use distinct test emails between attempts or wait 60s.
- Supabase `createClient(baseUrl, anonKey)` — even with `baseUrl = '/'`, the SDK internally hardcodes the API endpoint paths and ignores the base URL. There is no SDK-level workaround for a network-blocked Supabase. The only fix is replacing SDK calls with raw `fetch()` through a proxy.

## Active subdomains

| URL | Type | App |
|-----|------|-----|
| `https://dosishewan.duckdns.org` | file_server | Kalkulator Volume Obat Hewan |
| `https://hermesedi.duckdns.org` | reverse_proxy :8081 | File Browser (filebrowser) |
| `https://rra.edisantosa.com` | file_server | Sistem RRA (Supabase-connected SPA) |

## FileBrowser v2 Download Bug & Workaround

**FileBrowser v2.63.18 (and possibly other v2.x)** has a bug where the **Download button in the UI fails silently** — the API returns HTTP 200 but with `Content-Type: text/html` (the FileBrowser login page) instead of the file binary, even with a valid JWT Bearer token.

### Symptom
- FileBrowser UI loads correctly, you can browse files
- Selecting a file and clicking Download does nothing / fails
- API calls like `/api/download?paths=...` with valid Bearer token return HTML instead of file bytes

### Workaround: Simple Download Server on port 8082

A Node.js download server at `/home/ubuntu/dl_server.js` runs on port 8082 as a permanent fallback for direct file downloads.

**Server code** (`/home/ubuntu/dl_server.js`):
```js
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 8082;
const ROOT = '/home/ubuntu';

const mimeTypes = {
  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  '.doc': 'application/msword', '.pdf': 'application/pdf',
  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  '.xls': 'application/vnd.ms-excel',
  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  '.ppt': 'application/vnd.ms-powerpoint', '.zip': 'application/zip',
  '.txt': 'text/plain', '.html': 'text/html', '.png': 'image/png', '.jpg': 'image/jpeg',
};

const server = http.createServer((req, res) => {
  const filePath = path.join(ROOT, decodeURIComponent(req.pathname || req.url).replace(/^\//, ''));
  if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('Forbidden'); return; }
  try {
    const stat = fs.statSync(filePath);
    if (stat.isDirectory()) { /* serve index */ return; }
    const ct = mimeTypes[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
    res.writeHead(200, {'Content-Type': ct, 'Content-Disposition': 'attachment; filename="' + path.basename(filePath) + '"', 'Content-Length': stat.size});
    fs.createReadStream(filePath).pipe(res);
  } catch(e) { res.writeHead(404); res.end('Not found'); }
});
server.listen(PORT, () => console.log('Download server on port ' + PORT));
```

**Start it** (already running as background process `proc_0a4cc902020f`):
```bash
node /home/ubuntu/dl_server.js &
```

**Access pattern**: `https://hermesedi.duckdns.org/kantor/FILE_NAME` — Caddy serves `/home/ubuntu` root directly at `:80` catchall.

> **Note**: The `:80` catchall in the Caddyfile already serves `/home/ubuntu` as a browseable file server. Any file accessible from `/home/ubuntu/FILE_OR_FOLDER` can be reached directly via `https://hermesedi.duckdns.org/FILE_OR_FOLDER`.

## DNS Options

There are two viable paths:

### Option A: Cloudflare (for subdomains under edisantosa.com)

`edisantosa.com` is behind **Cloudflare proxy**. When deploying a NEW subdomain:

1. Go to [dash.cloudflare.com](https://dash.cloudflare.com) → **DNS** → **Records**
2. Create an A record pointing to `43.157.206.165`
3. **MUST set Proxy status to "DNS only"** (grey cloud) — otherwise Caddy's Let's Encrypt HTTP-01 challenge fails with `NXDOMAIN` because Cloudflare intercepts port 80.
4. After DNS propagates (~5-30 min), Caddy auto-obtains SSL cert.

Verify:
```bash
dig SUBDOMAIN.edisantosa.com A +short
host SUBDOMAIN.edisantosa.com
```

### Option B: Duck DNS (fallback — no Cloudflare required)

When Cloudflare DNS editing is unavailable or fails, use Duck DNS:

1. Open **https://www.duckdns.org** → login (Google / Reddit / Twitter / email)
2. Fill:
   - **subdomain**: desired name (e.g. `dosishewan`)
   - **IP**: `43.157.206.165`
3. Click **update**
4. Domain becomes: `SUBDOMAIN.duckdns.org` (note: `.org`, not `.com`)
5. Caddy auto-detects and obtains SSL — no DNS configuration needed on server side

> **Common Duck DNS mistake:** Use `.duckdns.org` NOT `.duckdns.com`

## Reverse Proxy: Deploying Web Apps (Python/Node/etc.)

For apps that run on a local port (e.g. Python Flask, filebrowser, custom servers), use Caddy `reverse_proxy` instead of `file_server`.

### Example: filebrowser on port 8081

```bash
# 1. Install filebrowser
curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash

# 2. Create directories and database
mkdir -p /home/ubuntu/filebrowser/{database,storage}

# 3. Generate password hash
hash=$(/usr/local/bin/filebrowser hash "YOUR_PASSWORD")
# Use bcrypt-style hash returned by the above command

# 4. Create config.json
cat > /home/ubuntu/filebrowser/config.json << EOF
{
  "port": 8081,
  "address": "127.0.0.1",
  "database": "/home/ubuntu/filebrowser/database/filebrowser.db",
  "root": "/home/ubuntu",
  "username": "admin",
  "password": "$hash",
  "locale": "en",
  "tempDir": "/tmp",
  "log": "stdout"
}
EOF

# 5. Start in background
/usr/local/bin/filebrowser --config /home/ubuntu/filebrowser/config.json &

# 6. Update Caddyfile with reverse_proxy
```

### Caddyfile for reverse proxy:

```bash
sudo bash -c 'cat > /etc/caddy/Caddyfile << '"'"'EOF'"'"'
SUBDOMAIN.duckdns.org {
    reverse_proxy localhost:8081
}

:80 {
    root * /home/ubuntu/kantor
    encode gzip
    file_server {
        precompressed
    }
    handle {
        try_files {path} {path}/ /index.html
        file_server
    }
}
EOF'
```

## DNS Propagation & ACME Timing

**Critical timing issue:** When a new subdomain DNS record is created, it may resolve locally but NOT yet from Let's Encrypt's ACME validation servers. This causes:
```
DNS problem: NXDOMAIN looking up A for SUBDOMAIN — check that a DNS record exists
```
This error persists even though your local `host` or `dig` already shows the correct IP. ACME server DNS propagation takes **5–30+ minutes** globally.

**What to do:**
1. Set DNS A record correctly (see DNS Options above)
2. Caddy's ACME client **retries automatically every 60–600 seconds** — do NOT restart Caddy, just wait
3. After 10–30 minutes, SSL cert will be obtained automatically with no intervention needed
4. Check Caddy's log via `process log` to monitor progress

**Temporary workaround while waiting:** Access via plain HTTP using the raw IP to test:
```bash
curl -s --max-time 5 http://43.157.206.165/rra-app/
```
This bypasses SSL entirely and is useful for testing before DNS/SSL is fully propagated.

## Troubleshooting SSL Errors

| Error | Cause | Fix |
|-------|-------|-----|
| `SSL_ERROR_INTERNAL_ERROR_ALERT` (Firefox) | Let's Encrypt cert not yet issued; browser trying to connect before ACME finished | Wait 10–30 min; Caddy auto-retries |
| `NXDOMAIN` in Caddy log for ACME | ACME server can't see DNS yet; local DNS already propagated | Wait 5–30 min for global DNS propagation |
| `tlsv1 alert internal error` from curl | Connection reaches Caddy but no cert yet | Wait for cert issuance |
| Browser: "Secure Connection Failed" | Same — cert not ready | Wait and refresh |
| Caddy uses staging ACME account | Normal on some Caddy versions — staging certs don't work in browsers | Caddy auto-upgrades to production on retry |

## Cloudflare Proxy Conflict

If `edisantosa.com` uses Cloudflare proxy (orange cloud), Cloudflare **terminates TLS** at their edge — Caddy never sees the request and can't serve SSL. The result is SSL errors or Cloudflare interstitial pages.

**Rule:** For any subdomain pointing to `43.157.206.165` that needs Caddy-managed SSL, the Cloudflare proxy MUST be set to **"DNS only"** (grey cloud), NOT "Proxied" (orange cloud).

Steps in Cloudflare dashboard:
1. DNS → Records → find the subdomain record
2. Click the cloud icon to toggle — it must be **grey (off)**
3. Save

## Let's Encrypt Gotchas

- **First SSL attempt may timeout** — Caddy's ACME client retries automatically every 60s. If `tls-alpn-01` challenge times out on first try, wait 2-3 minutes and check again. The cert usually succeeds on retry.
- **Do NOT kill/restart Caddy immediately** after DNS change — give Let's Encrypt validators time to reach your server. Caddy handles retries internally.
- Caddy auto-obtains and renews certificates — no manual `certbot` needed.

## File Locations on This Server

| Path | Description |
|------|-------------|
| `/home/ubuntu/kantor/index.html` | Main landing page |
| `/home/ubuntu/kantor/kalkulator-volume-obat.html` | Kalkulator volume obat |
| `/home/ubuntu/filebrowser/config.json` | filebrowser config |
| `/home/ubuntu/filebrowser/database/filebrowser.db` | filebrowser SQLite DB |

### Active subdomains:

| URL | Purpose |
|-----|---------|
| `https://dosishewan.duckdns.org` | Kalkulator Volume Obat Hewan |
| `https://hermesedi.duckdns.org` | File Browser (filebrowser) |
