> ## Documentation Index
> Fetch the complete documentation index at: https://controlplanecorporation-majid-docs-content-expansion.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# 6. Configure a custom domain

> Optional. Verify a domain you own, route it to your workload, and let Control Plane issue and renew its TLS certificate.

## Overview

<Note>
  This part is optional. Your application is already accessible at its `cpln.app` endpoint. Follow these steps to connect a domain you own.
</Note>

Since part 2 your application has answered on a generated `cpln.app` endpoint. This part puts it behind a name you own. `example.com` is a placeholder, so wherever it appears on this page, in a form, a file, or a command, use a domain you own. The domain is what makes the deployment production-facing. Users reach your name, and Control Plane issues and renews the certificate for it once a DNS record [proves the domain is yours](/reference/domain#domain-verification).

**What you'll build:**

* Your domain (`example.com`) verified with Control Plane and routed to the `frontend` [workload](/concepts/workload), with a [TLS certificate](/core/security#external-certificates) issued and renewed for you and the same geo-routed load balancing your application already has.
* The DNS records at your provider that make it resolve.

<img src="https://mintcdn.com/controlplanecorporation-majid-docs-content-expansion/6V8SaiZLWK_iHnrB/images/quickstart/custom-domain.svg?fit=max&auto=format&n=6V8SaiZLWK_iHnrB&q=85&s=f7600bd6486c581aa51353133958dfc6" alt="A TXT record at _cpln.example.com verifies domain ownership, and a CNAME record points example.com at abc123xyz.cpln.app. The domain routes to the frontend workload, with a certificate Control Plane manages." style={{maxWidth:'720px',width:'100%',margin:'1.75rem auto',display:'block'}} width="720" height="222" data-path="images/quickstart/custom-domain.svg" />

## Prerequisites

* A domain name you own, at a DNS provider that accepts a CNAME (or ALIAS) record on the domain itself.
* Completed [2. Deploy your own application](/quickstart/deploy-application) with the `frontend` workload running. That is all this part needs, so you can come here straight after part 2 or after finishing the series at [5. Observe your workload](/quickstart/observe-workload).

<Note>
  If your DNS provider does not accept a CNAME record on the domain itself, route a subdomain instead: use `app.example.com` wherever this part says `example.com`, and the CNAME host becomes `app` instead of `@`.
</Note>

<Tabs>
  <Tab title="Console" icon="display">
    ## Step 1: Describe the domain

    <Steps>
      <Step title="Open the domain form">
        Click `Domains` in the left menu, then click `New`.
      </Step>

      <Step title="Name the domain and pick the workload">
        Enter your domain as the `Domain` (this quickstart uses `example.com`), then select the `frontend` workload from the `quickstart-gvc` [GVC](/concepts/gvc).
      </Step>

      <Step title="Read the records the domain needs">
        Click `Create`, or click anywhere outside the workload field. The form checks the domain before creating anything and lists what it needs. Under `Ownership Config` is a TXT record whose value is your [org](/concepts/org)'s name; it proves you own the domain. Under `DNS Config` is the CNAME record that points the domain at your GVC, with your GVC's alias already filled in. `Export Zone File` downloads both.
      </Step>
    </Steps>

    ## Step 2: Add the DNS records

    Add both records at your DNS provider and wait for them to propagate.

    ## Step 3: Create the domain

    Click `Create` again. If the TXT record has not propagated yet, the Console refuses with `You need to prove ownership of the domain example.com by setting one of the following TXT records`; wait and try again. Once created, Control Plane issues the domain's TLS certificate as soon as the CNAME resolves and the workload is ready.

    <Tip>Create the domain in your production org as a best practice.</Tip>
  </Tab>

  <Tab title="CLI" icon="terminal">
    ## Step 1: Write the domain configuration

    Create `domain.yaml`:

    ```yaml theme={null}
    kind: domain
    name: example.com
    spec:
      dnsMode: cname
      ports:
        - number: 443
          protocol: http2
          routes:
            - prefix: /
              workloadLink: //gvc/quickstart-gvc/workload/frontend
    ```

    ## Step 2: Read the records the domain needs

    Apply the configuration:

    ```bash theme={null}
    cpln apply -f domain.yaml
    ```

    The command fails with `You need to prove ownership of the domain example.com by setting one of the following TXT records` and lists everything the domain needs: two TXT records at `_cpln.example.com`, either of which proves ownership, and a CNAME record:

    ```text theme={null}
    type: CNAME
    host: "@"
    value: <gvcAlias>.cpln.app
    ttl: 300
    ```

    `<gvcAlias>` stands for your GVC's alias. Read it from the GVC:

    ```bash theme={null}
    cpln gvc get quickstart-gvc -o yaml
    ```

    ```yaml theme={null}
    alias: abc123xyz
    ```

    ## Step 3: Add the DNS records

    At your DNS provider, add one of the TXT records from the error and the CNAME record, with your alias in place of `abc123xyz`:

    | Type  | Host | Value                | TTL |
    | ----- | ---- | -------------------- | --- |
    | CNAME | @    | `abc123xyz.cpln.app` | 300 |

    ## Step 4: Create the domain

    After the records propagate, apply the configuration again:

    ```bash theme={null}
    cpln apply -f domain.yaml
    ```

    The domain is created, and Control Plane issues its TLS certificate once the workload is ready.
  </Tab>

  <Tab title="Terraform" icon="https://mintcdn.com/controlplanecorporation-majid-docs-content-expansion/Ry1Mkgc7uPHC-gur/icons/terraform.svg?fit=max&auto=format&n=Ry1Mkgc7uPHC-gur&q=85&s=19deabd5e978d39905a6c83ea1f7904d" width="256" height="291" data-path="icons/terraform.svg">
    ## Step 1: Define the domain and its route

    Add the following to your `main.tf`:

    ```hcl theme={null}
    resource "cpln_domain" "site" {
      name        = "example.com"
      description = "The application's domain"

      spec {
        dns_mode = "cname"

        ports {
          number   = 443
          protocol = "http2"

          tls {}
        }
      }
    }

    resource "cpln_domain_route" "site" {
      depends_on = [cpln_domain.site]

      domain_link   = cpln_domain.site.self_link
      domain_port   = 443
      prefix        = "/"
      workload_link = cpln_workload.frontend.self_link
    }

    output "domain_endpoint" {
      value = "https://example.com"
    }

    output "dns_cname_record" {
      value = "Type: CNAME | Host: @ | Value: ${cpln_gvc.quickstart.alias}.cpln.app | TTL: 300"
    }
    ```

    ## Step 2: Read the records the domain needs

    ```bash theme={null}
    terraform apply
    ```

    The apply fails until you prove ownership: the error lists two TXT records at `_cpln.example.com`, either of which works.

    ## Step 3: Add the DNS records

    At your DNS provider, add one of the TXT records and the CNAME record from the `dns_cname_record` output, whose value has the form `<gvcAlias>.cpln.app`.

    ## Step 4: Create the domain

    After the records propagate, apply again:

    ```bash theme={null}
    terraform apply
    ```

    <Tip>
      The [Terraform Registry documentation](https://registry.terraform.io/providers/controlplane-com/cpln/latest/docs/resources/domain) lists every domain option.
    </Tip>
  </Tab>

  <Tab title="Pulumi" icon="https://mintcdn.com/controlplanecorporation-majid-docs-content-expansion/Ry1Mkgc7uPHC-gur/icons/pulumi.svg?fit=max&auto=format&n=Ry1Mkgc7uPHC-gur&q=85&s=7a7f4b9390dfa8fecf6223c88c658dcd" width="256" height="271" data-path="icons/pulumi.svg">
    ## Step 1: Define the domain and its route

    <Tabs>
      <Tab title="TypeScript">
        Add to your `index.ts`:

        ```typescript theme={null}
        // The application's domain
        const siteDomain = new cpln.Domain("site-domain", {
          name: "example.com",
          description: "The application's domain",
          spec: {
            dnsMode: "cname",
            ports: [
              {
                number: 443,
                protocol: "http2",
                tls: {},
              },
            ],
          },
        });

        // Route it to the application
        const siteRoute = new cpln.DomainRoute("site-route", {
          domainLink: siteDomain.selfLink,
          domainPort: 443,
          prefix: "/",
          workloadLink: frontend.selfLink,
        }, { dependsOn: [siteDomain] });

        export const domain_endpoint = "https://example.com";
        export const dns_cname_record = gvc.alias.apply(
          alias => `Type: CNAME | Host: @ | Value: ${alias}.cpln.app | TTL: 300`
        );
        ```
      </Tab>

      <Tab title="Python">
        Add to your `__main__.py`:

        ```python theme={null}
        # The application's domain
        site_domain = cpln.Domain("site-domain",
            name="example.com",
            description="The application's domain",
            spec=cpln.DomainSpecArgs(
                dns_mode="cname",
                ports=[cpln.DomainSpecPortArgs(
                    number=443,
                    protocol="http2",
                    tls=cpln.DomainSpecPortTlsArgs(),
                )],
            ))

        # Route it to the application
        site_route = cpln.DomainRoute("site-route",
            domain_link=site_domain.self_link,
            domain_port=443,
            prefix="/",
            workload_link=frontend.self_link,
            opts=pulumi.ResourceOptions(depends_on=[site_domain]))

        pulumi.export("domain_endpoint", "https://example.com")
        pulumi.export("dns_cname_record", gvc.alias.apply(
            lambda alias: f"Type: CNAME | Host: @ | Value: {alias}.cpln.app | TTL: 300"
        ))
        ```
      </Tab>

      <Tab title="Go">
        Add `fmt` to your imports, which the `dns_cname_record` output below uses, then add to your `main.go`:

        ```go theme={null}
        // The application's domain
        siteDomain, err := cpln.NewDomain(ctx, "site-domain", &cpln.DomainArgs{
        	Name:        pulumi.String("example.com"),
        	Description: pulumi.String("The application's domain"),
        	Spec: &cpln.DomainSpecArgs{
        		DnsMode: pulumi.String("cname"),
        		Ports: cpln.DomainSpecPortArray{
        			&cpln.DomainSpecPortArgs{
        				Number:   pulumi.Int(443),
        				Protocol: pulumi.String("http2"),
        				Tls:      &cpln.DomainSpecPortTlsArgs{},
        			},
        		},
        	},
        })
        if err != nil {
        	return err
        }

        // Route it to the application
        _, err = cpln.NewDomainRoute(ctx, "site-route", &cpln.DomainRouteArgs{
        	DomainLink:   siteDomain.SelfLink,
        	DomainPort:   pulumi.Int(443),
        	Prefix:       pulumi.String("/"),
        	WorkloadLink: frontend.SelfLink,
        }, pulumi.DependsOn([]pulumi.Resource{siteDomain}))
        if err != nil {
        	return err
        }

        ctx.Export("domain_endpoint", pulumi.String("https://example.com"))
        ctx.Export("dns_cname_record", gvc.Alias.ApplyT(func(alias string) string {
        	return fmt.Sprintf("Type: CNAME | Host: @ | Value: %s.cpln.app | TTL: 300", alias)
        }).(pulumi.StringOutput))
        ```
      </Tab>

      <Tab title="C#">
        Add to your `Program.cs`:

        ```csharp theme={null}
        // The application's domain
        var siteDomain = new Domain("site-domain", new DomainArgs
        {
            Name = "example.com",
            Description = "The application's domain",
            Spec = new DomainSpecArgs
            {
                DnsMode = "cname",
                Ports = new[]
                {
                    new DomainSpecPortArgs
                    {
                        Number = 443,
                        Protocol = "http2",
                        Tls = new DomainSpecPortTlsArgs {}
                    }
                }
            }
        });

        // Route it to the application
        var siteRoute = new DomainRoute("site-route", new DomainRouteArgs
        {
            DomainLink = siteDomain.SelfLink,
            DomainPort = 443,
            Prefix = "/",
            WorkloadLink = frontend.SelfLink
        }, new CustomResourceOptions { DependsOn = { siteDomain } });
        ```

        Update your return dictionary to include the new outputs:

        ```csharp theme={null}
        return new Dictionary<string, object?>
        {
            ["canonical_endpoint"] = web.Statuses.Apply(s => s[0].CanonicalEndpoint),
            ["frontend_endpoint"] = frontend.Statuses.Apply(s => s[0].CanonicalEndpoint),
            ["domain_endpoint"] = "https://example.com",
            ["dns_cname_record"] = gvc.Alias.Apply(alias =>
                $"Type: CNAME | Host: @ | Value: {alias}.cpln.app | TTL: 300")
        };
        ```
      </Tab>
    </Tabs>

    ## Step 2: Read the records the domain needs

    ```bash theme={null}
    pulumi up
    ```

    The deployment fails until you prove ownership: the error lists two TXT records at `_cpln.example.com`, either of which works.

    ## Step 3: Add the DNS records

    At your DNS provider, add one of the TXT records and the CNAME record from the `dns_cname_record` output, whose value has the form `<gvcAlias>.cpln.app`.

    ## Step 4: Create the domain

    After the records propagate, deploy again:

    ```bash theme={null}
    pulumi up
    ```

    <Tip>
      The [Pulumi Registry documentation](https://www.pulumi.com/registry/packages/cpln/api-docs/domain/) lists every domain option.
    </Tip>
  </Tab>

  <Tab title="AI Agent" icon="sparkles">
    ## Step 1: Describe the domain

    The first prompt names your org and the domain, so it works in a new conversation too. Replace `my-org` with your org name and `example.com` with your domain, here and in every prompt that follows:

    ```text theme={null}
    Using org "my-org", put the domain "example.com" in front of
    the frontend workload in GVC "quickstart-gvc", using a CNAME
    record at my DNS provider.
    ```

    The agent submits the domain, and Control Plane refuses it until a DNS record proves the domain is yours.

    ## Step 2: Read the records the domain needs

    The refusal lists what the domain needs, and the agent passes it on: a TXT record at `_cpln.example.com` with your org's name or its ID as the value, either of which proves ownership, and a CNAME record for the domain itself. The CNAME value has the form `<gvcAlias>.cpln.app`, and the agent reads your GVC's alias to fill it in. If the value it gives you still shows `<gvcAlias>`, ask for your GVC's alias.

    ## Step 3: Add the DNS records

    Add the TXT record and the CNAME record at your DNS provider and wait for them to propagate. If the agent offered two TXT records, one is enough.

    ## Step 4: Create the domain

    ```text theme={null}
    The DNS records are in place. Create the domain again.
    ```

    The agent submits the same domain again. If the TXT record has not propagated yet, Control Plane refuses once more. Wait and try again. Once created, Control Plane issues the domain's TLS certificate as soon as the CNAME resolves and the workload is ready. Ask for the status while you wait:

    ```text theme={null}
    What is the status of the domain now?
    ```

    The agent reads the domain back and reports `pendingCertificate` until the certificate is issued, then `ready`.
  </Tab>
</Tabs>

## Verify

Once the certificate is issued, open the domain in your browser. Your application loads over HTTPS with a valid TLS certificate, and its footer still names the [location](/concepts/location) that served the request.

<Check>
  Your application is served on your own domain with an automatic TLS certificate and geo-routed load balancing.
</Check>

## Routing modes

Control Plane supports two routing modes:

| Mode                | DNS record | Best for                                               |
| ------------------- | ---------- | ------------------------------------------------------ |
| **Path-based**      | CNAME      | Multiple workloads on different paths (`/api`, `/web`) |
| **Subdomain-based** | NS         | A unique subdomain per workload (`api.example.com`)    |

<AccordionGroup>
  <Accordion title="Path-based routing examples">
    Route different paths to different workloads:

    * `https://example.com/api` routes to the API workload.
    * `https://example.com/web` routes to the frontend workload.
    * `https://example.com/` routes to the default workload.
  </Accordion>

  <Accordion title="Subdomain-based routing examples">
    Each workload receives its own subdomain automatically:

    * `https://api.example.com` routes to the API workload.
    * `https://web.example.com` routes to the frontend workload.

    Requires NS record delegation to Control Plane.
  </Accordion>
</AccordionGroup>

## What you've learned

* **Ownership before certificates**: Control Plane issues a certificate for a name only after a TXT record proves you control it, and refuses the domain until that record resolves.
* **The CNAME carries everything**: pointing the domain at the GVC's alias brings the TLS termination, the certificate renewal, and the geo-routing the generated endpoint had.
* **One domain, many workloads**: a domain splits across workloads by path or by subdomain.

## Next steps

<CardGroup cols={2}>
  <Card title="3. Service-to-service communication" icon="arrows-left-right" href="/quickstart/connect-workloads">
    If you came here straight after part 2, this is where the series continues: the API, kept off the internet, reached by the frontend through the mesh.
  </Card>

  <Card title="Domain reference" icon="globe" href="/reference/domain">
    Every domain option: NS mode, subdomain routing, your own certificate, CORS, and the ports a domain can expose.
  </Card>
</CardGroup>

## Clean up

To remove everything the series has created. If you came here before finishing parts 3, 4, and 5, skip the policy, the release, the `quickstart-db` GVC, the secret, and the `api` image, since they do not exist yet:

<Tabs>
  <Tab title="Console" icon="display">
    <Steps>
      <Step title="Delete the domain">
        Open `Domains`, select your domain, click `Actions`, then `Delete`, and confirm.
      </Step>

      <Step title="Delete the policy">
        Open `Policies`, select `api-db-policy`, click `Actions`, then `Delete`, and confirm.
      </Step>

      <Step title="Uninstall the database release">
        Under `Templates`, click `Releases`, open `db`, click `Actions`, then `Uninstall`, and confirm.
      </Step>

      <Step title="Delete the database GVC">
        Open `quickstart-db`, click `Actions`, then `Delete`, type the GVC name to confirm, and click `Delete`.
      </Step>

      <Step title="Delete the application GVC">
        Open `quickstart-gvc`, click `Actions`, then `Delete`, type the GVC name to confirm, and click `Delete`. `web`, `frontend`, `api`, `api-identity`, and the tracing setting go with it.
      </Step>

      <Step title="Delete the secret">
        Open `Secrets`, select `db-credentials`, click `Actions`, then `Delete`, and confirm.
      </Step>

      <Step title="Delete the images">
        Open `Images`, select `frontend`, and click `Actions`, then `Delete` to remove all its tags. Repeat for `api`.
      </Step>
    </Steps>
  </Tab>

  <Tab title="CLI" icon="terminal">
    ```bash theme={null}
    cpln domain delete example.com
    cpln policy delete api-db-policy
    cpln helm uninstall db
    cpln gvc delete quickstart-db
    cpln gvc delete quickstart-gvc
    cpln secret delete db-credentials
    cpln image delete frontend:1.0
    cpln image delete frontend:1.1
    cpln image delete api:1.0
    ```

    Replace `example.com` with your domain.
  </Tab>

  <Tab title="Terraform" icon="https://mintcdn.com/controlplanecorporation-majid-docs-content-expansion/Ry1Mkgc7uPHC-gur/icons/terraform.svg?fit=max&auto=format&n=Ry1Mkgc7uPHC-gur&q=85&s=19deabd5e978d39905a6c83ea1f7904d" width="256" height="291" data-path="icons/terraform.svg">
    ```bash theme={null}
    terraform destroy
    ```

    The images were built by the CLI, so delete them with it: `cpln image delete frontend:1.0`, `cpln image delete frontend:1.1`, and `cpln image delete api:1.0`.
  </Tab>

  <Tab title="Pulumi" icon="https://mintcdn.com/controlplanecorporation-majid-docs-content-expansion/Ry1Mkgc7uPHC-gur/icons/pulumi.svg?fit=max&auto=format&n=Ry1Mkgc7uPHC-gur&q=85&s=7a7f4b9390dfa8fecf6223c88c658dcd" width="256" height="271" data-path="icons/pulumi.svg">
    ```bash theme={null}
    pulumi destroy
    ```

    The images were built by the CLI, so delete them with it: `cpln image delete frontend:1.0`, `cpln image delete frontend:1.1`, and `cpln image delete api:1.0`.
  </Tab>

  <Tab title="AI Agent" icon="sparkles">
    ```text theme={null}
    Delete the domain "example.com" and the policy "api-db-policy",
    uninstall the release "db", then delete the GVCs "quickstart-db"
    and "quickstart-gvc" and the images frontend:1.0, frontend:1.1,
    and api:1.0.
    ```

    Replace `example.com` with your domain. The agent lists what goes, including the release's volume set and the data on it and the tracing setting with its GVC, and asks you to confirm. The agent cannot delete a secret, so remove that yourself:

    ```bash theme={null}
    cpln secret delete db-credentials
    ```
  </Tab>
</Tabs>

<Note>
  Uninstalling the release removes its volume set and the data on it. Remove the DNS records from your DNS provider after deleting the domain. A `--remote` build also pushes the build cache images `frontend-cache:latest` and `api-cache:latest`; delete those too if you built without Docker.
</Note>
