Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions dev-server-provision/configurator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,23 @@ def _ask_import(config: dict[str, Any]) -> None:
def _ask_domain_config(config: dict[str, Any]) -> None:
_heading("Domain & DNS")

has_domain = inquirer.confirm(
message="Do you have a domain name for this server?",
default=True,
).execute()

if not has_domain:
config["ip_only"] = True
config["use_cloudflare"] = False
print(
f"\n {_YELLOW}IP-only mode selected.{_RESET}\n"
f" Coder will be accessible via plain HTTP on your server's public IP\n"
f" (e.g. http://<server-ip>). No TLS certificate will be provisioned.\n"
)
return

config["ip_only"] = False

config["domain"] = inquirer.text(
message="Root domain (e.g. example.com):",
default=config.get("domain") or "",
Expand Down Expand Up @@ -188,6 +205,26 @@ def _ask_domain_config(config: dict[str, Any]) -> None:
def _ask_cloudflare_config(config: dict[str, Any]) -> None:
_heading("Cloudflare DNS")

use_cf = inquirer.confirm(
message="Use Cloudflare to manage DNS automatically?",
default=config.get("use_cloudflare", True),
).execute()

config["use_cloudflare"] = use_cf

if not use_cf:
subdomain = config.get("subdomain", "<subdomain>")
domain = config.get("domain", "<domain>")
fqdn = f"{subdomain}.{domain}"
print(
f"\n {_YELLOW}Manual DNS mode.{_RESET} Please create the following DNS record\n"
f" at your DNS provider before or shortly after provisioning:\n\n"
f" {'Type':<8} {'Name':<30} {'Value':<20} {'TTL'}\n"
f" {'-'*8} {'-'*30} {'-'*20} {'-'*5}\n"
f" {'A':<8} {fqdn:<30} {'<server-public-ip>':<20} 3600\n"
)
return

config["cloudflare_api_token"] = inquirer.secret(
message="Cloudflare API Token (Zone:DNS:Edit):",
default=config.get("cloudflare_api_token") or "",
Expand Down Expand Up @@ -773,8 +810,9 @@ def run() -> None:
# 2. Domain & DNS
_ask_domain_config(config)

# 3. Cloudflare
_ask_cloudflare_config(config)
# 3. Cloudflare (skip entirely in ip_only mode)
if not config.get("ip_only"):
_ask_cloudflare_config(config)

# 4. Coder admin password
_ask_coder_admin_password(config)
Expand Down Expand Up @@ -803,8 +841,12 @@ def run() -> None:
_offer_deploy(provider, deploy_config, output_file)

_heading("Done")
fqdn = f"{config['subdomain']}.{config['domain']}"
print(f" After provisioning (~5 min), access Coder at: {_CYAN}https://{fqdn}{_RESET}")
if config.get("ip_only"):
print(f" After provisioning (~5 min), access Coder at: {_CYAN}http://<server-ip>{_RESET}")
print(f" {_YELLOW}(Replace <server-ip> with the actual public IP of your server){_RESET}")
else:
fqdn = f"{config['subdomain']}.{config['domain']}"
print(f" After provisioning (~5 min), access Coder at: {_CYAN}https://{fqdn}{_RESET}")
print()
print(f" {_BOLD}Bare-server install:{_RESET}")
print(f" Copy {_CYAN}RVSconfig.yml{_RESET} to your server, then run:")
Expand Down
28 changes: 26 additions & 2 deletions dev-server-provision/configurator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
permissions: "0600"
owner: root:root
content: |
# ── Deployment mode ───────────────────────────────────────────────
# IP_ONLY=true → no domain / TLS; Coder is served via plain HTTP on
# the server's public IP (set by setup.sh at boot).
# USE_CLOUDFLARE=false → domain is provided but DNS is managed manually.
IP_ONLY={ip_only}
USE_CLOUDFLARE={use_cloudflare}

# ── Domain & DNS ──────────────────────────────────────────────────
DOMAIN={domain}
SUBDOMAIN={subdomain}
Expand All @@ -59,8 +66,10 @@
CLOUDFLARE_ZONE_ID={cloudflare_zone_id}

# ── Coder ─────────────────────────────────────────────────────────
CODER_URL=https://{subdomain}.{domain}
CODER_ACCESS_URL=https://{subdomain}.{domain}
# CODER_URL / CODER_ACCESS_URL are set dynamically by setup.sh
# (ip_only: http://<PUBLIC_IP> | domain: https://<subdomain>.<domain>)
CODER_URL={coder_url}
CODER_ACCESS_URL={coder_url}
# Admin login password (Coder requires ≥ 8 chars, no spaces).
# Login email is the address entered above. Username: admin
CODER_ADMIN_PASSWORD={coder_admin_password}
Expand Down Expand Up @@ -161,6 +170,7 @@ def generate_cloud_init(config: dict[str, Any]) -> str:

*config* must contain the keys used in ``_CLOUD_INIT_TEMPLATE``.
Boolean agent flags are normalised to ``"true"`` / ``"false"``.
For ip_only mode the CODER_URL is left blank (setup.sh fills it at boot).
"""
# Normalise booleans → lowercase strings
normalised: dict[str, str] = {}
Expand All @@ -169,12 +179,24 @@ def generate_cloud_init(config: dict[str, Any]) -> str:
normalised[key] = "true" if value else "false"
else:
normalised[key] = str(value) if value is not None else ""

# Compute coder_url: empty for ip_only (setup.sh will fill it at boot),
# otherwise https://<subdomain>.<domain>
if config.get("ip_only"):
normalised["coder_url"] = ""
else:
subdomain = normalised.get("subdomain", "")
domain = normalised.get("domain", "")
normalised["coder_url"] = f"https://{subdomain}.{domain}" if subdomain and domain else ""

return _CLOUD_INIT_TEMPLATE.format(**normalised)


def default_config() -> dict[str, Any]:
"""Return a config dict pre-filled with safe defaults."""
return {
"ip_only": False,
"use_cloudflare": True,
"domain": "",
"subdomain": "dev",
"email": "",
Expand Down Expand Up @@ -202,6 +224,8 @@ def default_config() -> dict[str, Any]:
# The key order used when writing RVSconfig.yml — keeps the output tidy and
# predictable.
_RVS_KEY_ORDER: list[str] = [
"ip_only",
"use_cloudflare",
"domain",
"subdomain",
"email",
Expand Down
97 changes: 97 additions & 0 deletions dev-server-provision/configurator/tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def _full_config(self, **overrides):
"email": "admin@example.com",
"cloudflare_api_token": "a" * 40,
"cloudflare_zone_id": "a" * 32,
"coder_admin_password": "securepass1",
"enable_agent_copilot": False,
"enable_agent_claude": False,
"enable_agent_gemini": False,
Expand Down Expand Up @@ -343,6 +344,102 @@ def test_opencode_unknown_provider_rejected(self):
self.assertFalse(agent_check.passed)
self.assertIn("not supported", agent_check.message)

# ---- ip_only mode --------------------------------------------------------

def test_ip_only_passes_with_only_password(self):
"""ip_only=True: all domain/CF fields can be empty."""
config = {
"ip_only": True,
"use_cloudflare": False,
"domain": "",
"subdomain": "",
"email": "",
"cloudflare_api_token": "",
"cloudflare_zone_id": "",
"coder_admin_password": "securepass1",
"enable_agent_copilot": False,
"enable_agent_claude": False,
"enable_agent_gemini": False,
"enable_agent_codex": False,
"enable_agent_opencode": False,
"openai_api_key": "",
"anthropic_api_key": "",
"google_api_key": "",
"github_token": "",
"codex_openai_auth_code": "",
"opencode_provider": "",
}
results = run_preflight_checks(config, provider="aws")
required_check = results[0]
self.assertTrue(required_check.passed, f"ip_only check failed: {required_check.message}")

def test_ip_only_missing_password_fails(self):
"""ip_only=True: coder_admin_password is still required."""
config = {
"ip_only": True,
"use_cloudflare": False,
"domain": "",
"subdomain": "",
"email": "",
"cloudflare_api_token": "",
"cloudflare_zone_id": "",
"coder_admin_password": "",
"enable_agent_copilot": False,
"enable_agent_claude": False,
"enable_agent_gemini": False,
"enable_agent_codex": False,
"enable_agent_opencode": False,
"openai_api_key": "",
"anthropic_api_key": "",
"google_api_key": "",
"github_token": "",
"codex_openai_auth_code": "",
"opencode_provider": "",
}
results = run_preflight_checks(config, provider="aws")
required_check = results[0]
self.assertFalse(required_check.passed)
self.assertIn("coder_admin_password", required_check.message)

# ---- use_cloudflare=False mode -------------------------------------------

def test_no_cloudflare_passes_without_cf_fields(self):
"""use_cloudflare=False: CF token/zone not required, but domain/email are."""
config = self._full_config(
use_cloudflare=False,
cloudflare_api_token="",
cloudflare_zone_id="",
)
results = run_preflight_checks(config, provider="aws")
required_check = results[0]
self.assertTrue(required_check.passed, f"no-CF check failed: {required_check.message}")

def test_no_cloudflare_missing_domain_fails(self):
"""use_cloudflare=False: domain is still required."""
config = self._full_config(
use_cloudflare=False,
domain="",
cloudflare_api_token="",
cloudflare_zone_id="",
)
results = run_preflight_checks(config, provider="aws")
required_check = results[0]
self.assertFalse(required_check.passed)
self.assertIn("domain", required_check.message)

def test_no_cloudflare_missing_email_fails(self):
"""use_cloudflare=False: email is still required (for Let's Encrypt)."""
config = self._full_config(
use_cloudflare=False,
email="",
cloudflare_api_token="",
cloudflare_zone_id="",
)
results = run_preflight_checks(config, provider="aws")
required_check = results[0]
self.assertFalse(required_check.passed)
self.assertIn("email", required_check.message)


class TestPreflightResult(unittest.TestCase):
def test_repr_pass(self):
Expand Down
27 changes: 24 additions & 3 deletions dev-server-provision/configurator/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def validate_coder_password(value: str) -> str | bool:
return True



def validate_api_key_optional(value: str) -> str | bool:
"""Accept empty or any non-whitespace string."""
return True

Expand Down Expand Up @@ -131,8 +131,29 @@ def __repr__(self) -> str:


def _check_required_fields(config: dict[str, Any]) -> PreflightResult:
"""All required fields must be non-empty."""
required = ["domain", "subdomain", "email", "cloudflare_api_token", "cloudflare_zone_id", "coder_admin_password"]
"""All required fields must be non-empty.

Which fields are required depends on the deployment mode:
- ``ip_only=True``: only ``coder_admin_password`` is required — no domain,
no Cloudflare credentials needed.
- ``use_cloudflare=False`` (domain mode, manual DNS): domain / subdomain /
email are required but Cloudflare credentials are not.
- default (Cloudflare-managed DNS): all domain + CF fields are required.
"""
ip_only = config.get("ip_only", False)
use_cloudflare = config.get("use_cloudflare", True)

if ip_only:
required: list[str] = ["coder_admin_password"]
elif not use_cloudflare:
required = ["domain", "subdomain", "email", "coder_admin_password"]
else:
required = [
"domain", "subdomain", "email",
"cloudflare_api_token", "cloudflare_zone_id",
"coder_admin_password",
]

missing = [f for f in required if not config.get(f)]
if missing:
return PreflightResult("Required fields", False, f"Missing: {', '.join(missing)}")
Expand Down
52 changes: 50 additions & 2 deletions dev-server-provision/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,63 @@ This guide walks through deploying a fully automated remote development server f

1. **A cloud provider account** — Hetzner is recommended (affordable, EU-based, great API). Any provider supporting cloud-init works (AWS, GCP, Azure, DigitalOcean, etc.).

2. **A domain name** with DNS managed by Cloudflare.
2. **A domain name** *(optional)* — required only for HTTPS with automatic TLS. See **IP-only Quickstart** below if you don't have one.

3. **A Cloudflare API token** — with `Zone → DNS → Edit` permission for your domain's zone.
3. **A Cloudflare API token** *(optional)* — with `Zone → DNS → Edit` permission. Required only when Cloudflare manages your DNS. For manual DNS or IP-only deployments this is not needed.

4. **Server requirements:**
- Ubuntu 24.04 LTS
- Minimum: 2 vCPUs, 4 GB RAM, 40 GB SSD (Hetzner CPX21 or larger)
- Recommended: 4 vCPUs, 8 GB RAM, 80 GB SSD (Hetzner CPX31)

## IP-only Quickstart (no domain required)

If you don't have a domain name, you can deploy RemoteVibeServer in **IP-only
mode** — Coder is served over plain HTTP on your server's public IP (port 80).

No Cloudflare token, no domain, no TLS certificate needed.

### 1. Generate your config

Run the configurator:

```bash
cd dev-server-provision
python -m configurator
```

When asked *"Do you have a domain name?"* — answer **No**.
The configurator will set `ip_only: true` in your config automatically.

### 2. Deploy via cloud-init

```bash
hcloud server create \
--name dev-server \
--type cpx21 \
--image ubuntu-24.04 \
--location nbg1 \
--user-data-from-file cloud-init.yaml
```

### 3. Access Coder

After ~5 minutes:

```
http://<server-public-ip>
```

Find the IP with:

```bash
hcloud server describe dev-server | grep "Public Net"
# or on the server:
cat /etc/dev-server/status
```

> **Note:** HTTP-only means traffic is unencrypted. Suitable for local/private networks or quick evaluation. For production use, add a domain and enable HTTPS.

## Step 1: Prepare the Cloud-Init File

1. Copy the example file:
Expand Down
20 changes: 19 additions & 1 deletion dev-server-provision/infra/dns.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,25 @@ log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] [dns] $*" | tee -a "$LOG_FILE";
die() { log "ERROR: $*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# Validate inputs
# IP-only / no-Cloudflare mode — skip DNS automation
# ---------------------------------------------------------------------------
if [[ "${IP_ONLY:-false}" == "true" || -z "${DOMAIN:-}" ]]; then
log "IP-only mode — no DNS record needed. Skipping DNS step."
exit 0
fi

if [[ -z "${CLOUDFLARE_API_TOKEN:-}" || -z "${CLOUDFLARE_ZONE_ID:-}" ]]; then
log "Cloudflare credentials not configured — skipping automated DNS."
log "Please create the following DNS record manually at your DNS provider:"
log " Type : A"
log " Name : ${SUBDOMAIN:-<subdomain>}.${DOMAIN:-<domain>}"
log " Value: ${PUBLIC_IP:-<server-public-ip>}"
log " TTL : 3600"
exit 0
fi

# ---------------------------------------------------------------------------
# Validate remaining inputs
# ---------------------------------------------------------------------------
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
: "${CLOUDFLARE_ZONE_ID:?CLOUDFLARE_ZONE_ID is required}"
Expand Down
Loading