---
name: deploy-public-site
description: "Deploy static sites and simple web apps as permanent, publicly-accessible services — survives reboots, no SSH dependency."
version: 1.0.0
author: Pipo
metadata:
  hermes:
    tags: [deploy, static-site, systemd, cloudflare-tunnel, tailscale-funnel, permanent]
---

# Deploy Public Site

Make a static site or simple web app permanently accessible from the public internet. The site must survive server reboots, process crashes, and SSH disconnections.

## Core Rule

**Never deploy a site that depends on a foreground process or SSH tunnel.** If the user has to keep a terminal open for the site to work, it's not deployed — it's demoed. Use systemd services + tunnel infrastructure.

## Architecture

```
Internet → Cloudflare Tunnel (trycloudflare.com) → localhost:PORT
       ↘ Tailscale Funnel (tailnet URL)        ↗
         Python/Node server (systemd) ────────┘
```

Three layers, all persistent:
1. **Web server** as a systemd service (auto-restarts, boots with system)
2. **Cloudflare Tunnel** as a systemd service (public HTTPS URL, auto-restarts)
3. **Tailscale Funnel** as backup (already running, HTTPS, tailnet-authenticated)

## Step-by-Step

### 1. Create the site files

Put all HTML/CSS/JS in a directory, e.g. `/home/ubuntu/my-site/index.html`.

### 2. Create the web server systemd service

```bash
# Write service file
cat > /tmp/my-site.service << 'EOF'
[Unit]
Description=My Site
After=network.target

[Service]
Type=simple
WorkingDirectory=/home/ubuntu/my-site
ExecStart=/usr/bin/python3 -m http.server 5000 --bind 127.0.0.1
Restart=always
RestartSec=5
User=ubuntu

[Install]
WantedBy=multi-user.target
EOF

sudo cp /tmp/my-site.service /etc/systemd/system/my-site.service
sudo systemctl daemon-reload
sudo systemctl enable my-site
sudo systemctl start my-site
```

**Verify:** `curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/` → 200

### 3. Install cloudflared (if not present)

```bash
arch=$(uname -m)
[ "$arch" = "aarch64" ] && arch="arm64"
[ "$arch" = "x86_64" ] && arch="amd64"
curl -L -o /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}"
chmod +x /usr/local/bin/cloudflared
```

### 4. Create Cloudflare Tunnel systemd service

```bash
cat > /tmp/cloudflared-my-site.service << 'EOF'
[Unit]
Description=Cloudflare Tunnel for My Site
After=network.target my-site.service
Requires=my-site.service

[Service]
Type=simple
ExecStart=/usr/local/bin/cloudflared tunnel --url http://localhost:5000
Restart=always
RestartSec=10
User=ubuntu
Environment=HOME=/home/ubuntu

[Install]
WantedBy=multi-user.target
EOF

sudo cp /tmp/cloudflared-my-site.service /etc/systemd/system/cloudflared-my-site.service
sudo systemctl daemon-reload
sudo systemctl enable cloudflared-my-site
sudo systemctl start cloudflared-my-site
```

**Get the URL:** `sudo journalctl -u cloudflared-my-site --no-pager -n 30 | grep trycloudflare`

### 5. Set up Tailscale Funnel (backup)

```bash
tailscale funnel 5000  # Maps to https://HOSTNAME.tailnet.ts.net/
```

This is already persistent if Tailscale is enabled on boot (`sudo systemctl is-enabled tailscaled`).

### 6. Verify everything

```bash
echo "=== Service status ==="
sudo systemctl is-active my-site
sudo systemctl is-active cloudflared-my-site
sudo systemctl is-active tailscaled

echo "=== URLs ==="
echo "Cloudflare: $(sudo journalctl -u cloudflared-my-site --no-pager | grep -o 'https://.*trycloudflare.com' | head -1)"
echo "Tailscale:  https://$(tailscale status --json | python3 -c 'import sys,json;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')/"
```

## Option B: Deploy to Vercel (static sites)

For static sites (HTML/CSS/JS, no server-side logic), Vercel is the simplest option — no systemd, no tunnels, no cloudflared. The site lives on Vercel's CDN permanently.

### Prerequisites

- `vercel` CLI installed (`npm i -g vercel`)
- `VERCEL_TOKEN` env var set (or run `vercel login` for interactive auth)

### Deploy

```bash
cd /path/to/site-directory
vercel deploy --prod --yes --token="$VERCEL_TOKEN"
```

This outputs:
- **Production URL:** `https://<project>-<hash>-<team>.vercel.app`
- **Alias URL:** `https://<project-name>.vercel.app` (stable, doesn't change on redeploy)

### Verify

```bash
curl -s -o /dev/null -w "%{http_code}" https://<alias>.vercel.app/
```

### Pitfalls specific to Vercel

1. **Vercel auto-detects frameworks.** For plain HTML, it deploys the directory as-is (output directory = `.`). If it tries to run `npm run build`, you may need a `vercel.json` with `"outputDirectory": "."`.

2. **`--yes` flag skips confirmation prompts.** Essential for non-interactive deploys.

3. **Project is auto-linked on first deploy.** Vercel creates a `.vercel/` directory. Subsequent deploys from the same directory reuse the project. Add `.vercel/` to `.gitignore`.

4. **Token auth is non-interactive.** `vercel whoami --token="$VERCEL_TOKEN"` verifies the token works. If it returns "No existing credentials", the env var isn't set.

## Pitfalls

1. **Heredocs in terminal tool fail.** The terminal tool detects heredocs as "long-lived processes." Write service files with `write_file` to `/tmp/` first, then `sudo cp` to `/etc/systemd/system/`. Never use `cat << EOF` or `tee` with heredocs in the terminal tool — it blocks with a background process warning.

2. **Oracle Cloud blocks non-standard ports.** Port 5000 is NOT accessible from the public internet directly. Must use tunnels (Cloudflare/Tailscale) for public access.

3. **`--bind 127.0.0.1` is intentional.** The server only listens locally; tunnels handle public routing. Binding to `0.0.0.0` exposes the raw server without TLS.

4. **trycloudflare URLs change on restart.** Account-less Cloudflare tunnels get a new random URL each time the service restarts. For a stable domain, use a Cloudflare Named Tunnel with a custom domain (requires Cloudflare account).

5. **Tailscale Funnel is fully public.** `tailscale funnel` gives a public HTTPS URL (e.g. `https://HOST.tailnet.ts.net/`) accessible from any browser — no Tailscale client needed. Cloudflare Tunnel is still better for a clean domain, but Tailscale Funnel URLs work as permanent entry points.

6. **systemd service naming.** Use descriptive names: `my-site.service`, `cloudflared-my-site.service`. Avoid generic names that could collide.

7. **Adding a path to existing Tailscale Funnel fails with "listener already exists".** When Funnel is already running on port 443 (or any port), `tailscale serve --set-path /new/ http://127.0.0.1:PORT` fails with "listener already exists for port 443". You **must** reset and rebuild the entire config. First dump the current config with `tailscale serve status -json`, then `tailscale serve reset`, then re-add ALL handlers including the new one, then re-enable funnel for each port. See `references/tailscale-funnel-multi-path.md` for the full procedure.

8. **Funnel CLI syntax changed** (Tailscale v1.80+). `tailscale funnel 443 on` no longer works. Use `tailscale funnel --bg --set-path / http://127.0.0.1:PORT` — same syntax as `tailscale serve`.

## For One-Off Demos (Temporary)

If the user just needs to show something quickly (not permanent), `localhost.run` works:

```bash
ssh -R 80:localhost:5000 nokey@localhost.run
```

⚠️ **This is NOT permanent.** It dies when the SSH session ends. Only use when the user explicitly wants temporary access.
