Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ CLAUDE.md

# Generated operator configs (environment-specific, must not be committed)
nginx-auth.conf.generated
domains.config.json
vercel.redirects.json

# Vercel
.vercel/
Expand Down
104 changes: 102 additions & 2 deletions docs/devdocify/how-to/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,110 @@ For link-specific validation, run:
npm run lint-content
```

## Custom domain
## Custom domains

To attach a custom domain:
DevDocify supports single-domain, multi-domain, and legacy domain redirect configurations. Domain setup is managed through `domains.config.json` and automated via the Vercel API.

### Single domain

To attach one custom domain:

1. Go to **Settings > Domains** in your Vercel project.
2. Enter your domain and follow the DNS configuration instructions.
3. Vercel provisions a TLS certificate automatically.

Or use the CLI:

```bash
VERCEL_TOKEN=<token> VERCEL_PROJECT_ID=<id> npm run manage-domains add docs.example.com
```

### Multi-domain setup

For multiple domains (e.g. apex redirect, per-docset subdomains), use the domain configuration file.

1. Copy the example config:

```bash
cp domains.config.example.json domains.config.json
```

2. Edit `domains.config.json` with your domains:

```json
{
"primaryDomain": "docs.example.com",
"aliases": [
{ "domain": "example.com", "redirectToPrimary": true }
],
"docsetDomains": [
{ "domain": "api.example.com", "docsetId": "petstore", "basePath": "/" }
]
}
```

3. Validate the configuration:

```bash
npm run validate-domains -- --config domains.config.json
```

4. Sync all domains to Vercel:

```bash
VERCEL_TOKEN=<token> VERCEL_PROJECT_ID=<id> npm run manage-domains sync --config domains.config.json
```

5. Check verification status:

```bash
VERCEL_TOKEN=<token> VERCEL_PROJECT_ID=<id> npm run manage-domains list
```

### Legacy domain redirects

When migrating from an old domain, configure redirects so existing bookmarks and external links continue working.

Add entries to the `legacyRedirects` array in `domains.config.json`:

```json
{
"legacyRedirects": [
{
"fromDomain": "old-docs.example.com",
"toDomain": "docs.example.com",
"statusCode": 308,
"preservePath": true
}
]
}
```

Generate the Vercel redirect rules:

```bash
npm run generate-domain-redirects -- --config domains.config.json --output vercel.redirects.json
```

Merge the generated `redirects` array into your `vercel.json`.

### DNS configuration

Configure DNS records at your DNS provider. Common patterns:

| Domain type | Record | Name | Value |
|---|---|---|---|
| Subdomain (www, docs, api) | CNAME | `www` | `cname.vercel-dns.com` |
| Apex domain | A | `@` | `76.76.21.21` |

After adding DNS records, verify the domain:

```bash
VERCEL_TOKEN=<token> VERCEL_PROJECT_ID=<id> npm run manage-domains verify docs.example.com
```

### Troubleshooting

- **Domain not verifying.** DNS propagation can take up to 48 hours. Run `npm run manage-domains verify <domain>` to check status and see required DNS records.
- **SSL certificate pending.** Vercel provisions TLS certificates automatically after DNS verification. Allow a few minutes after verification completes.
- **Redirect loops.** Run `npm run validate-domains` to detect redirect chains or circular references in your domain configuration.
34 changes: 34 additions & 0 deletions domains.config.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"_comment": "Copy this file to domains.config.json and configure your domains. Never commit domains.config.json.",
"primaryDomain": "www.devdocify.com",
"aliases": [
{
"domain": "devdocify.com",
"redirectToPrimary": true
}
],
"docsetDomains": [
{
"_comment": "Optional: serve a docset on its own subdomain. Readers visiting docs.example.com see the docset at /.",
"domain": "docs.example.com",
"docsetId": "devdocify",
"basePath": "/"
}
],
"legacyRedirects": [
{
"_comment": "Redirect traffic from a retired domain to the current site, preserving the URL path.",
"fromDomain": "old-docs.example.com",
"toDomain": "www.devdocify.com",
"statusCode": 308,
"preservePath": true
}
],
"dns": {
"_comment": "Reference only. Actual DNS records must be configured at your DNS provider.",
"expectedRecords": [
{"type": "CNAME", "name": "www", "value": "cname.vercel-dns.com"},
{"type": "A", "name": "@", "value": "76.76.21.21"}
]
}
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"validate-assistant-quality": "npx tsx scripts/validate-assistant-quality.ts",
"generate-nginx-auth-config": "npx tsx scripts/generate-nginx-auth-config.ts",
"check-rbac-permission": "npx tsx scripts/check-rbac-permission.ts",
"validate-domains": "npx tsx scripts/validate-domains-config.ts",
"manage-domains": "npx tsx scripts/manage-vercel-domains.ts",
"generate-domain-redirects": "npx tsx scripts/generate-domain-redirects.ts",
"test": "npx tsx --test scripts/__tests__/*.test.ts"
},
"dependencies": {
Expand Down
124 changes: 124 additions & 0 deletions scripts/generate-domain-redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Legacy domain redirect generator: Epic 13, Story 13.3
*
* Reads domains.config.json and generates a Vercel redirects configuration
* fragment from legacyRedirects and alias entries. Output can be merged into
* vercel.json or used as input for other hosting providers.
*
* Usage:
* npx tsx scripts/generate-domain-redirects.ts [--config <path>] [--output <path>]
* npx tsx scripts/generate-domain-redirects.ts --config domains.config.json --output vercel.redirects.json
*
* If --output is omitted, the result is printed to stdout.
*/

import fs from 'fs';
import path from 'path';
import type { DomainsConfig } from './validate-domains-config';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type VercelRedirect = {
source: string;
destination: string;
statusCode: number;
has?: Array<{ type: string; key?: string; value: string }>;
};

// ---------------------------------------------------------------------------
// Generator
// ---------------------------------------------------------------------------

export function generateRedirects(config: DomainsConfig): VercelRedirect[] {
const redirects: VercelRedirect[] = [];

// Alias redirects (e.g. apex -> www)
if (config.aliases) {
for (const alias of config.aliases) {
if (!alias.redirectToPrimary) continue;
redirects.push({
source: '/:path(.*)',
destination: `https://${config.primaryDomain}/:path`,
statusCode: 308,
has: [{ type: 'host', value: alias.domain }],
});
}
}

// Legacy domain redirects
if (config.legacyRedirects) {
for (const lr of config.legacyRedirects) {
const statusCode = lr.statusCode ?? 308;
if (lr.preservePath !== false) {
redirects.push({
source: '/:path(.*)',
destination: `https://${lr.toDomain}/:path`,
statusCode,
has: [{ type: 'host', value: lr.fromDomain }],
});
} else {
redirects.push({
source: '/:path(.*)',
destination: `https://${lr.toDomain}/`,
statusCode,
has: [{ type: 'host', value: lr.fromDomain }],
});
}
}
}

return redirects;
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

function parseArgs(): { configPath: string; outputPath: string | null } {
const args = process.argv.slice(2);
const configIdx = args.indexOf('--config');
const configPath = configIdx !== -1 && args[configIdx + 1]
? path.resolve(process.cwd(), args[configIdx + 1])
: path.resolve(process.cwd(), 'domains.config.json');

const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 && args[outputIdx + 1]
? path.resolve(process.cwd(), args[outputIdx + 1])
: null;

return { configPath, outputPath };
}

const { configPath, outputPath } = parseArgs();

if (!fs.existsSync(configPath)) {
console.error(`[domain-redirects] ERROR: config file not found: ${configPath}`);
console.error(' Copy domains.config.example.json to domains.config.json and configure your domains.');
process.exit(1);
}

let config: DomainsConfig;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
console.error(`[domain-redirects] ERROR: failed to parse JSON: ${(err as Error).message}`);
process.exit(1);
}

const redirects = generateRedirects(config);

if (redirects.length === 0) {
console.log('[domain-redirects] No redirects to generate.');
process.exit(0);
}

const output = JSON.stringify({ redirects }, null, 2);

if (outputPath) {
fs.writeFileSync(outputPath, output + '\n', 'utf8');
console.log(`[domain-redirects] ${redirects.length} redirect(s) written to ${outputPath}`);
} else {
console.log(output);
}
Loading
Loading