Skip to content

Commit 9087442

Browse files
committed
added DOMAIN_ALIAS concept with weighted records; still need to test extensively and add double TXT support
1 parent b488e75 commit 9087442

7 files changed

Lines changed: 317 additions & 19 deletions

File tree

custom-domain/dstack-ingress/DNS_PROVIDERS.md

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@ This guide explains how to configure dstack-ingress to work with different DNS p
1313

1414
### Common Variables (Required for all providers)
1515

16-
- `DOMAIN` - Your custom domain (e.g., `app.example.com`)
16+
- `DOMAIN` - Your node-specific domain (e.g., `node1.app.example.com`)
1717
- `GATEWAY_DOMAIN` - dstack gateway domain (e.g., `_.dstack-prod5.phala.network`)
1818
- `CERTBOT_EMAIL` - Email for Let's Encrypt registration
1919
- `TARGET_ENDPOINT` - Backend application endpoint to proxy to
20-
- `DNS_PROVIDER` - DNS provider to use (`cloudflare`, `linode`, `namecheap`)
20+
- `DNS_PROVIDER` - DNS provider to use (`cloudflare`, `linode`, `namecheap`, `route53`)
2121

2222
### Optional Variables
2323

2424
- `SET_CAA` - Enable CAA record setup (default: false)
2525
- `PORT` - HTTPS port (default: 443)
2626
- `TXT_PREFIX` - Prefix for TXT records (default: "_tapp-address")
27+
- `ALIAS_DOMAIN` - Public-facing domain shared across multiple nodes (e.g., `app.example.com`). Added as a SAN on the TLS certificate and to nginx `server_name`. When set alongside `ROUTE53_INITIAL_WEIGHT` (Route53 only), also creates a weight-0 weighted CNAME `ALIAS_DOMAIN → DOMAIN` to register this node in the pool without routing traffic to it. See [Weighted Routing with ALIAS_DOMAIN](#weighted-routing-with-alias_domain-route53).
2728

2829
## Provider-Specific Configuration
2930

@@ -104,11 +105,12 @@ PolicyDocument:
104105
```
105106
106107
**Optional Variables for Route53:**
107-
- `ROUTE53_INITIAL_WEIGHT` - Enables [Weighted Routing](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-weighted.html) on the CNAME record created for your domain. Set to an integer (e.g., `100`) to assign that weight to the record. A unique `SetIdentifier` is generated automatically. TXT records (used for Certbot DNS challenges) are never weighted. Omit this variable to create a standard non-weighted CNAME record.
108+
- `ROUTE53_INITIAL_WEIGHT` - Enables [Weighted Routing](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-weighted.html) on the CNAME record created for `DOMAIN` (e.g. `node1.app.example.com → phala gateway`). Set to an integer (e.g., `100`) to assign that weight to the record. A unique `SetIdentifier` is generated automatically. TXT records are never weighted. Omit to create a standard non-weighted CNAME. When `ALIAS_DOMAIN` is also set, this variable additionally triggers creation of a weight-0 weighted CNAME `ALIAS_DOMAIN → DOMAIN` — see below.
109+
- `ALIAS_DOMAIN` - See [Weighted Routing with ALIAS_DOMAIN](#weighted-routing-with-alias_domain-route53).
108110

109111
**Important Notes for Route53:**
110-
- The certbot plugin uses the format `certbot-dns-route53` package
111-
- CAA will merge AWS & Let's Encrypt CA domains to existing records if they exist
112+
- The certbot plugin uses the `certbot-dns-route53` package
113+
- CAA will merge AWS & Let's Encrypt CA domains into existing records if they exist
112114
- It is essential that the AWS service account used can only assume the limited role. See cloudformation example.
113115

114116
## Docker Compose Examples
@@ -165,22 +167,22 @@ services:
165167
- ./evidences:/evidences
166168
```
167169

168-
### Route53 Example
170+
### Route53 Example (single node, no weighted routing)
169171

170172
```yaml
171173
services:
172174
dstack-ingress:
173-
image: dstack-ingress:latest
175+
image: dstacktee/dstack-ingress:20250929@sha256:2b47b3e538df0b3e7724255b89369194c8c83a7cfba64d2faf0115ad0a586458
174176
restart: unless-stopped
175177
volumes:
176-
- /var/run/dstack.sock:/var/run/dstack.sock
177-
- cert-data:/etc/letsencrypt
178+
- /var/run/tappd.sock:/var/run/tappd.sock
179+
- cert-data:/etc/letsencrypt
178180
ports:
179-
- 443:443
181+
- "443:443"
180182
environment:
181183
DNS_PROVIDER: route53
182184
DOMAIN: app.example.com
183-
GATEWAY_DOMAIN: _.${DSTACK_GATEWAY_DOMAIN}
185+
GATEWAY_DOMAIN: _.dstack-prod5.phala.network
184186
185187
AWS_REGION: ${AWS_REGION}
186188
AWS_ROLE_ARN: ${AWS_ROLE_ARN}
@@ -190,9 +192,99 @@ services:
190192
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
191193
TARGET_ENDPOINT: http://backend:8080
192194
SET_CAA: 'true'
195+
volumes:
196+
cert-data:
197+
```
198+
199+
For multi-node weighted routing, see [Weighted Routing with ALIAS_DOMAIN](#weighted-routing-with-alias_domain-route53).
200+
201+
## Weighted Routing with ALIAS_DOMAIN (Route53)
202+
203+
This pattern lets you run multiple independent TEE nodes behind a single public domain using Route53 weighted routing. Each node manages its own Phala identity (TXT record, CNAME to gateway) while also being registered as a weighted target for the shared public domain.
204+
205+
### DNS Record Layout
206+
207+
```
208+
Public domain (shared across nodes)
209+
────────────────────────────────────────────────────────────────
210+
app.example.com CNAME node1.app.example.com weight=100 id=node1.app.example.com
211+
app.example.com CNAME node2.app.example.com weight=0 id=node2.app.example.com ← new, dark
212+
213+
Per-node records (managed by each node's dstack-ingress)
214+
────────────────────────────────────────────────────────────────
215+
node1.app.example.com CNAME <appid1>.dstack-prod5.phala.network weight=100
216+
node2.app.example.com CNAME <appid2>.dstack-prod5.phala.network weight=100
217+
218+
_dstack-app-address.node1.app.example.com TXT <appid1>:443
219+
_dstack-app-address.node2.app.example.com TXT <appid2>:443
220+
221+
TLS certificate (on each node)
222+
────────────────────────────────────────────────────────────────
223+
Subject: node1.app.example.com
224+
SAN: app.example.com
225+
```
193226
227+
### Variable Interactions
228+
229+
| `ALIAS_DOMAIN` | `ROUTE53_INITIAL_WEIGHT` | Cert SAN | Nginx server_name | Weight-0 CNAME for ALIAS_DOMAIN |
230+
|---|---|---|---|---|
231+
| not set | any | no | no | no |
232+
| set | not set | yes | yes | no |
233+
| set | set | yes | yes | yes |
234+
235+
### Node Startup Sequence
236+
237+
When a new node starts with both variables set, dstack-ingress performs these steps automatically:
238+
239+
```
240+
1. CNAME node2.app.example.com → <appid2>.dstack.phala.network (weighted, ROUTE53_INITIAL_WEIGHT)
241+
2. TXT _dstack-app-address.node2.app.example.com → <appid2>:443
242+
3. CNAME app.example.com → node2.app.example.com (weight=0, SetIdentifier=node2.app.example.com)
243+
4. Obtain TLS cert for node2.app.example.com + app.example.com (SAN)
194244
```
195245
246+
The node is fully verified and ready before step 3 introduces it to the pool. Traffic only starts flowing after an operator explicitly sets the weight above 0 in Route53.
247+
248+
### Docker Compose Example (weighted, two-node setup)
249+
250+
Node 1 (`node1.app.example.com`):
251+
252+
```yaml
253+
services:
254+
dstack-ingress:
255+
image: dstacktee/dstack-ingress:20250929@sha256:2b47b3e538df0b3e7724255b89369194c8c83a7cfba64d2faf0115ad0a586458
256+
restart: unless-stopped
257+
ports:
258+
- "443:443"
259+
environment:
260+
DNS_PROVIDER: route53
261+
DOMAIN: node1.app.example.com
262+
ALIAS_DOMAIN: app.example.com
263+
GATEWAY_DOMAIN: _.dstack-prod5.phala.network
264+
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
265+
TARGET_ENDPOINT: http://app:80
266+
SET_CAA: 'true'
267+
ROUTE53_INITIAL_WEIGHT: '100'
268+
269+
AWS_REGION: ${AWS_REGION}
270+
AWS_ROLE_ARN: ${AWS_ROLE_ARN}
271+
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
272+
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
273+
volumes:
274+
- /var/run/tappd.sock:/var/run/tappd.sock
275+
- cert-data:/etc/letsencrypt
276+
volumes:
277+
cert-data:
278+
```
279+
280+
Node 2 (`node2.app.example.com`) uses identical config with `DOMAIN: node2.app.example.com`. On first boot it registers itself at weight 0. To promote it, update the record weight in Route53.
281+
282+
### Important Notes
283+
284+
- The weight-0 CNAME for `ALIAS_DOMAIN` uses `DOMAIN` as the `SetIdentifier`, so each node has a stable, unique slot in the weighted record set that survives restarts without creating duplicates.
285+
- For non-Route53 providers (Cloudflare, Linode, Namecheap), `ALIAS_DOMAIN` still adds the SAN and updates nginx, but those providers do not support weighted CNAME records at the DNS level. You would need to manage traffic distribution through the provider's own load balancing features.
286+
- `ALIAS_DOMAIN` must be in the same Route53 hosted zone as `DOMAIN`, or at least a zone your credentials can write to.
287+
196288
## Migration from Cloudflare-only Setup
197289

198290
If you're currently using the Cloudflare-only version:

custom-domain/dstack-ingress/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ configs:
184184
- `PROXY_BUFFERS`: Optional value for nginx `proxy_buffers` (format: `number size`, e.g. `4 256k`) in single-domain mode
185185
- `PROXY_BUSY_BUFFERS_SIZE`: Optional value for nginx `proxy_busy_buffers_size` (numeric with optional `k|m` suffix, e.g. `256k`) in single-domain mode
186186
- `CERTBOT_STAGING`: Optional; set this value to the string `true` to set the `--staging` server option on the [`certbot` cli](https://eff-certbot.readthedocs.io/en/stable/using.html#certbot-command-line-options)
187+
- `ALIAS_DOMAIN`: Optional; a single public-facing domain shared across multiple nodes (e.g. `app.example.com`). Added as a SAN on the TLS certificate and to nginx `server_name`. When combined with `ROUTE53_INITIAL_WEIGHT` (Route53 only), also registers a weight-0 weighted CNAME `ALIAS_DOMAIN → DOMAIN` so the node is in the pool but dark until promoted. See [Multi-Node Weighted Routing](#multi-node-weighted-routing-with-alias_domain).
187188

188189
**Backward Compatibility:**
189190

@@ -252,6 +253,68 @@ volumes:
252253
cert-data:
253254
```
254255

256+
## Multi-Node Weighted Routing with ALIAS_DOMAIN
257+
258+
`ALIAS_DOMAIN` enables a pattern where multiple independent TEE nodes share a single public-facing domain via DNS weighted routing, while each node maintains its own Phala-verified identity.
259+
260+
### How It Works
261+
262+
Each node has a **node domain** (`DOMAIN`, e.g. `node1.app.example.com`) that is the primary identity used for Phala's TXT-based app routing. A single **public domain** (`ALIAS_DOMAIN`, e.g. `app.example.com`) is shared across all nodes and used as the user-facing address.
263+
264+
```
265+
┌─────────────────────────────────────────┐
266+
Users │ Route53 Weighted CNAMEs │
267+
│ │ │
268+
└─► app.example.com ───┼──► node1.app.example.com (weight=100) ─┼──► <appid1>.dstack.phala.network
269+
│ │
270+
└──► node2.app.example.com (weight=0) ──┼──► <appid2>.dstack.phala.network
271+
272+
┌─────────────────────────────────────────┘
273+
│ Phala Gateway TXT Routing
274+
275+
_dstack-app-address.node1.app.example.com → <appid1>:443
276+
_dstack-app-address.node2.app.example.com → <appid2>:443
277+
278+
│ TLS Certificate (on each node)
279+
280+
│ node1.app.example.com ← primary
281+
│ app.example.com ← SAN
282+
└─────────────────────────────────────────
283+
```
284+
285+
### What dstack-ingress Does Automatically
286+
287+
When `ALIAS_DOMAIN` is set:
288+
289+
1. **Certificate** — issues a SAN cert covering both `DOMAIN` and `ALIAS_DOMAIN`, so nginx can serve TLS regardless of which hostname the client connected through.
290+
2. **Nginx** — adds `ALIAS_DOMAIN` to `server_name` so requests arriving via the public domain are accepted.
291+
3. **Weighted CNAME** *(Route53 only, requires `ROUTE53_INITIAL_WEIGHT`)* — creates a weighted CNAME record `ALIAS_DOMAIN → DOMAIN` at **weight 0**, registering this node in the pool without routing any traffic to it yet. The `SetIdentifier` is set to `DOMAIN` so each node occupies a unique, stable slot.
292+
293+
### Lifecycle
294+
295+
```
296+
Node starts
297+
298+
├── CNAME: node1.app.example.com → <appid>.dstack.phala.network (weight=ROUTE53_INITIAL_WEIGHT)
299+
├── TXT: _dstack-app-address.node1.app.example.com → <appid>:443
300+
├── CNAME: app.example.com → node1.app.example.com (weight=0) ← new node, dark
301+
└── CERT: node1.app.example.com + app.example.com (SAN)
302+
303+
Operator promotes node
304+
└── Update Route53: app.example.com → node1.app.example.com weight 0 → desired weight
305+
```
306+
307+
The node is fully provisioned and verified before receiving any user traffic. Traffic is enabled by a deliberate operator action (bumping the weight in Route53), not automatically.
308+
309+
### Configuration
310+
311+
| Variable | Required | Description |
312+
|---|---|---|
313+
| `ALIAS_DOMAIN` | No | Public-facing domain shared across nodes (e.g. `app.example.com`) |
314+
| `ROUTE53_INITIAL_WEIGHT` | No | Weight for this node's primary CNAME. When combined with `ALIAS_DOMAIN`, also triggers creation of the weight-0 CNAME for the public domain. |
315+
316+
See [DNS Provider Configuration](DNS_PROVIDERS.md#weighted-routing-with-alias_domain-route53) for a full example.
317+
255318
## Domain Attestation and Verification
256319
257320
The dstack-ingress system provides mechanisms to verify and attest that your custom domain endpoint is secure and properly configured. This comprehensive verification approach ensures the integrity and authenticity of your application.

custom-domain/dstack-ingress/scripts/certman.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
287287

288288
if action == "certonly":
289289
base_cmd.extend(["--agree-tos", "--no-eff-email",
290-
"--email", email, "-d", domain])
290+
"--email", email, "--cert-name", domain, "-d", domain])
291+
alias_domain = os.environ.get("ALIAS_DOMAIN", "").strip()
292+
if alias_domain:
293+
base_cmd.extend(["--expand", "-d", alias_domain])
291294
if os.environ.get("CERTBOT_STAGING", "false") == "true":
292295
base_cmd.extend(["--staging"])
293296

custom-domain/dstack-ingress/scripts/dns_providers/base.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,36 @@ def set_txt_record(self, name: str, content: str, ttl: int = 60) -> bool:
247247
)
248248
return self.create_dns_record(new_record)
249249

250+
def set_weighted_cname_record(
251+
self,
252+
name: str,
253+
content: str,
254+
weight: int,
255+
set_identifier: str,
256+
ttl: int = 60,
257+
) -> bool:
258+
"""Set a weighted CNAME record.
259+
260+
Default implementation ignores weight/set_identifier and creates a plain
261+
CNAME. Providers that support weighted routing (e.g. Route53) should
262+
override this method.
263+
264+
Args:
265+
name: The record name (ALIAS_DOMAIN)
266+
content: The CNAME target (primary DOMAIN)
267+
weight: Routing weight (0 = registered but receives no traffic)
268+
set_identifier: Unique identifier for this node in the weighted pool
269+
ttl: Time to live
270+
271+
Returns:
272+
True if successful, False otherwise
273+
"""
274+
print(
275+
f"Note: provider does not support weighted CNAMEs; "
276+
f"creating plain CNAME for {name}"
277+
)
278+
return self.set_cname_record(name, content, ttl)
279+
250280
def set_caa_record(
251281
self,
252282
name: str,

custom-domain/dstack-ingress/scripts/dns_providers/route53.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ def set_alias_record(self, name: str, content: str, ttl: int = 60, proxied: bool
224224
want_weight = int(env_weight) if env_weight and env_weight.isdigit() else None
225225

226226
existing_records = self.get_dns_records(name, RecordType.CNAME)
227+
record_to_replace = None
227228
for record in existing_records:
228229
if record.content == content:
229230
has_weight = record.data and "weight" in record.data
@@ -234,15 +235,17 @@ def set_alias_record(self, name: str, content: str, ttl: int = 60, proxied: bool
234235
print("Weighted CNAME record with the same content and weight already exists")
235236
return True
236237
# Weight config mismatch — delete existing record before upserting,
237-
# because Route53 forbids mixing weighted and non-weighted RRSets
238-
# with the same name and type.
239-
has_weight = record.data and "weight" in record.data
240-
if (want_weight is not None and not has_weight) or (want_weight is None and has_weight):
238+
# because Route53 forbids mixing weighted and non-weighted RRSets
239+
# with the same name and type.
240+
record_to_replace = record
241+
break
242+
if record_to_replace is not None:
243+
has_weight = record_to_replace.data and "weight" in record_to_replace.data
241244
print(
242245
f"Deleting existing {'non-weighted' if not has_weight else 'weighted'} "
243246
f"CNAME before creating {'weighted' if want_weight is not None else 'non-weighted'} one"
244247
)
245-
if not self.delete_dns_record(record.id, name):
248+
if not self.delete_dns_record(record_to_replace.id, name):
246249
print(f"Error: Failed to delete existing CNAME record", file=sys.stderr)
247250
return False
248251

@@ -256,6 +259,52 @@ def set_alias_record(self, name: str, content: str, ttl: int = 60, proxied: bool
256259
)
257260
return self.create_dns_record(new_record)
258261

262+
def set_weighted_cname_record(
263+
self,
264+
name: str,
265+
content: str,
266+
weight: int,
267+
set_identifier: str,
268+
ttl: int = 60,
269+
) -> bool:
270+
"""Create or update a weighted CNAME record with an explicit weight.
271+
272+
Unlike set_alias_record, this bypasses ROUTE53_INITIAL_WEIGHT and uses
273+
the provided weight directly. set_identifier should be the primary node
274+
domain so each node occupies a unique slot in the weighted pool.
275+
"""
276+
existing_records = self.get_dns_records(name, RecordType.CNAME)
277+
for record in existing_records:
278+
if record.data and record.data.get("set_identifier") == set_identifier:
279+
if record.content == content and record.data.get("weight") == weight:
280+
print(
281+
f"Weighted CNAME for {name} "
282+
f"(id={set_identifier}, weight={weight}) already exists"
283+
)
284+
return True
285+
# Same identifier, different weight or content — delete and recreate
286+
print(
287+
f"Updating weighted CNAME for {name} "
288+
f"(id={set_identifier}) to weight={weight}"
289+
)
290+
if record.id and not self.delete_dns_record(record.id, name):
291+
print(
292+
f"Error: Failed to delete existing weighted CNAME",
293+
file=sys.stderr,
294+
)
295+
return False
296+
break
297+
298+
new_record = DNSRecord(
299+
id=None,
300+
name=name,
301+
type=RecordType.CNAME,
302+
content=content,
303+
ttl=ttl,
304+
data={"weight": weight, "set_identifier": set_identifier},
305+
)
306+
return self.create_dns_record(new_record)
307+
259308
def create_dns_record(self, record: DNSRecord) -> bool:
260309
"""
261310
Create a DNS record.

0 commit comments

Comments
 (0)