> ## 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.

# 3. Service-to-service communication

> Build the quickstart API, run it as a workload with no public endpoint, and connect the frontend to it over the internal network with automatic mTLS.

## Overview

The launch page gets its backend. The quickstart API is built from source like the frontend and runs as a [workload](/concepts/workload) with no public endpoint. The frontend reaches it the way workloads reach each other on Control Plane, over an internal endpoint the service mesh encrypts with [mTLS](/core/security#internal-certificates) using certificates it manages for you, and only once the API's firewall names the frontend as a caller. That is the zero-trust shape a production backend wants, and you will watch the default deny block the first call before you allow it.

**What you'll build:**

* A container image of the quickstart API in your [org](/concepts/org)'s [private registry](/reference/image#private-registry).
* An `api` workload in `quickstart-gvc`, reachable only over the internal network.
* The `frontend` workload calling `api`, allowed by `api`'s internal firewall, with the waitlist open.

<img src="https://mintcdn.com/controlplanecorporation-majid-docs-content-expansion/6V8SaiZLWK_iHnrB/images/quickstart/service-to-service.svg?fit=max&auto=format&n=6V8SaiZLWK_iHnrB&q=85&s=636df405b49555f5e7724bff1a95b9c2" alt="Inside the GVC quickstart-gvc, the workload frontend calls the workload api at api.quickstart-gvc.cpln.local:8080 over the internal network, encrypted by the mesh. frontend keeps its public endpoint and api has none, and api admits frontend by name on its internal firewall. Two workloads talk inside the GVC; the API answers only the callers its firewall names." style={{maxWidth:'720px',width:'100%',margin:'1.75rem auto',display:'block'}} width="720" height="246" data-path="images/quickstart/service-to-service.svg" />

<Accordion title="How internal communication works">
  Workloads reach each other through [internal endpoints](/reference/workload/general#internal-mutual-tls-endpoint) of the form:

  ```text theme={null}
  http://WORKLOAD_NAME.GVC_NAME.cpln.local:PORT
  ```

  The receiving workload's [internal firewall](/reference/workload/firewall#internal) decides who may connect. A new workload starts with `none`, so no other workload reaches it, not even one in the same [GVC](/concepts/gvc). The API keeps that default until you name `frontend` on its workload list.
</Accordion>

## Prerequisites

* Completed [2. Deploy your own application](/quickstart/deploy-application) with the `frontend` workload running.
* The CLI installed and logged in, as in part 2, to build the API's image.

## Step 1: Download the API

<CardGroup cols={2}>
  <Card title="macOS / Linux" icon="download" href="https://controlplane.com/downloads/quickstarts/api.tgz">
    Downloads `api.tgz`, a gzipped tar archive.
  </Card>

  <Card title="Windows" icon="download" href="https://controlplane.com/downloads/quickstarts/api.zip">
    Downloads `api.zip`, the same source as a zip archive.
  </Card>
</CardGroup>

Extract the archive and navigate to the directory:

<Tabs>
  <Tab title="macOS / Linux">
    ```bash theme={null}
    tar -xvf api.tgz && cd api
    ```
  </Tab>

  <Tab title="Windows PowerShell">
    ```powershell theme={null}
    Expand-Archive api.zip && cd api
    ```
  </Tab>
</Tabs>

The API is a Node.js service with three routes, all in `src/server.ts`:

| Route           | Answer                                                                                     |
| --------------- | ------------------------------------------------------------------------------------------ |
| `GET /`         | The service name, version, [location](/concepts/location), and which storage is in use     |
| `GET /signups`  | The signup count and the ten latest signups, each stamped with the location that stored it |
| `POST /signups` | Stores an email address                                                                    |

It keeps the signups in memory. Part 4 gives it a database.

## Step 2: Build and push the image

<Tabs>
  <Tab title="With Docker">
    ```bash theme={null}
    cpln image build --name api:1.0 --push
    ```
  </Tab>

  <Tab title="Without Docker">
    ```bash theme={null}
    cpln image build --name api:1.0 --remote
    ```
  </Tab>
</Tabs>

The command ends by printing the image it pushed, `your-org.registry.cpln.io/api:1.0` where `your-org` is the name of your org, together with its link. Workloads reference it as `//image/api:1.0`.

<Tabs>
  <Tab title="Console" icon="display">
    ## Step 3: Create the API workload

    <Steps>
      <Step title="Navigate to Workloads">
        With `quickstart-gvc` as the current context, click `Workloads` in the left menu, then click `New`.
      </Step>

      <Step title="Configure basic settings">
        Enter `api` as the name and make sure `quickstart-gvc` is the selected GVC.
      </Step>

      <Step title="Configure the container">
        Click `Containers` in the left pane and keep `Control Plane` as the image source. In the image dropdown, type `api` and select `api:1.0`. Under `Ports`, keep protocol `http` and number `8080`.
      </Step>

      <Step title="Create it with the firewall closed">
        Click `Create`. A new workload has no public endpoint, and under `Firewall`, `Internal`, its `Inbound Allow Type` is `None`: nothing reaches it yet.
      </Step>
    </Steps>

    ## Step 4: Point the frontend at the API

    <Steps>
      <Step title="Add the environment variable">
        Open the `frontend` workload, click `Containers` in the left pane, and open the `Env Vars` tab. Click `Add Environment Variable`, enter `API_URL` as the `Name`, keep `Literal Value` as the value type, and enter `http://api.quickstart-gvc.cpln.local:8080` as the value.
      </Step>

      <Step title="Update">
        Click `Update`. The workload rolls out a new version.
      </Step>
    </Steps>

    ## Step 5: See the default deny

    Wait until `frontend` reports `Ready` again, then open its [canonical endpoint](/reference/workload/general#canonical-endpoint-global). The page waits for its call to the API to time out, then reports below the waitlist form: `Could not reach the API at http://api.quickstart-gvc.cpln.local:8080: no answer within 3 seconds.` The name resolves and the connection is never answered, because `api` admits no caller yet.

    ## Step 6: Allow the frontend

    <Steps>
      <Step title="Open the API's internal firewall">
        Open the `api` workload, click `Firewall` in the left pane, then `Internal`.
      </Step>

      <Step title="Switch to a workload list">
        Set `Inbound Allow Type` to `Workload List`. Under `Inbound Allow List`, click `Add Workload`, select `frontend`, and confirm.
      </Step>

      <Step title="Update">
        Click `Update`.
      </Step>
    </Steps>
  </Tab>

  <Tab title="CLI" icon="terminal">
    ## Step 3: Create the API workload

    ```bash theme={null}
    cpln workload create --name api \
      --image //image/api:1.0 \
      --gvc quickstart-gvc \
      --port 8080
    ```

    Without `--public` the workload has no public endpoint, and its internal firewall starts as `none`: nothing reaches it yet.

    ## Step 4: Point the frontend at the API

    ```bash theme={null}
    cpln workload update frontend --gvc quickstart-gvc \
      --set spec.containers.frontend.env.API_URL.value=http://api.quickstart-gvc.cpln.local:8080
    ```

    ## Step 5: See the default deny

    Once `frontend` reports `Ready` again, open it:

    ```bash theme={null}
    cpln workload open frontend --gvc quickstart-gvc
    ```

    The page waits for its call to the API to time out, then reports below the waitlist form: `Could not reach the API at http://api.quickstart-gvc.cpln.local:8080: no answer within 3 seconds.` The name resolves and the connection is never answered, because `api` admits no caller yet.

    ## Step 6: Allow the frontend

    ```bash theme={null}
    cpln workload update api --gvc quickstart-gvc \
      --set spec.firewallConfig.internal.inboundAllowType=workload-list \
      --set spec.firewallConfig.internal.inboundAllowWorkload=frontend
    ```

    The CLI expands `frontend` to the workload's full link inside the same GVC.
  </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 3: Define the API workload

    Add to your `main.tf`. Without an external firewall rule the workload has no public endpoint, and its internal firewall starts as `none`:

    ```hcl theme={null}
    resource "cpln_workload" "api" {
      gvc  = cpln_gvc.quickstart.name
      name = "api"
      type = "standard"

      container {
        name   = "api"
        image  = "/org/my-org/image/api:1.0"
        cpu    = "50m"
        memory = "128Mi"

        ports {
          protocol = "http"
          number   = 8080
        }
      }

      options {
        capacity_ai     = true
        timeout_seconds = 5

        autoscaling {
          metric    = "disabled"
          target    = 95
          min_scale = 1
          max_scale = 1
        }
      }

      firewall_spec {
        internal {
          inbound_allow_type = "none"
        }
      }
    }
    ```

    <Note>
      Replace `my-org` with your org name; Terraform needs the full image path.
    </Note>

    ## Step 4: Point the frontend at the API

    Add the environment variable to the `frontend` container in `cpln_workload.frontend`:

    ```hcl theme={null}
        env = {
          API_URL = "http://api.quickstart-gvc.cpln.local:8080"
        }
    ```

    Apply both changes:

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

    ## Step 5: See the default deny

    Wait until `frontend` reports `Ready` again, then open its endpoint from the `frontend_endpoint` output. The page waits for its call to the API to time out, then reports below the waitlist form: `Could not reach the API at http://api.quickstart-gvc.cpln.local:8080: no answer within 3 seconds.` The name resolves and the connection is never answered, because `api` admits no caller yet.

    ## Step 6: Allow the frontend

    Name `frontend` on the API's workload list in `cpln_workload.api`:

    ```hcl theme={null}
      firewall_spec {
        internal {
          inbound_allow_type     = "workload-list"
          inbound_allow_workload = [cpln_workload.frontend.self_link]
        }
      }
    ```

    Apply the change:

    ```bash theme={null}
    terraform apply
    ```
  </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 3: Define the API workload

    Without an external firewall rule the workload has no public endpoint, and its internal firewall starts as `none`.

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

        ```typescript theme={null}
        // The quickstart API: internal only
        const api = new cpln.Workload("api", {
          gvc: gvc.name,
          name: "api",
          type: "standard",
          containers: [
            {
              name: "api",
              image: "/org/my-org/image/api:1.0",
              cpu: "50m",
              memory: "128Mi",
              ports: [{ protocol: "http", number: 8080 }],
            },
          ],
          options: {
            capacityAi: true,
            timeoutSeconds: 5,
            autoscaling: {
              metric: "disabled",
              target: 95,
              minScale: 1,
              maxScale: 1,
            },
          },
          firewallSpec: {
            internal: {
              inboundAllowType: "none",
            },
          },
        });
        ```
      </Tab>

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

        ```python theme={null}
        # The quickstart API: internal only
        api = cpln.Workload("api",
            gvc=gvc.name,
            name="api",
            type="standard",
            containers=[cpln.WorkloadContainerArgs(
                name="api",
                image="/org/my-org/image/api:1.0",
                cpu="50m",
                memory="128Mi",
                ports=[cpln.WorkloadContainerPortArgs(
                    protocol="http",
                    number=8080,
                )],
            )],
            options=cpln.WorkloadOptionsArgs(
                capacity_ai=True,
                timeout_seconds=5,
                autoscaling=cpln.WorkloadOptionsAutoscalingArgs(
                    metric="disabled",
                    target=95,
                    min_scale=1,
                    max_scale=1,
                ),
            ),
            firewall_spec=cpln.WorkloadFirewallSpecArgs(
                internal=cpln.WorkloadFirewallSpecInternalArgs(
                    inbound_allow_type="none",
                ),
            ))
        ```
      </Tab>

      <Tab title="Go">
        Add inside `pulumi.Run` in your `main.go`:

        ```go theme={null}
        // The quickstart API: internal only
        api, err := cpln.NewWorkload(ctx, "api", &cpln.WorkloadArgs{
        	Gvc:  gvc.Name,
        	Name: pulumi.String("api"),
        	Type: pulumi.String("standard"),
        	Containers: cpln.WorkloadContainerArray{
        		&cpln.WorkloadContainerArgs{
        			Name:   pulumi.String("api"),
        			Image:  pulumi.String("/org/my-org/image/api:1.0"),
        			Cpu:    pulumi.String("50m"),
        			Memory: pulumi.String("128Mi"),
        			Ports: cpln.WorkloadContainerPortArray{
        				&cpln.WorkloadContainerPortArgs{
        					Protocol: pulumi.String("http"),
        					Number:   pulumi.Int(8080),
        				},
        			},
        		},
        	},
        	Options: &cpln.WorkloadOptionsArgs{
        		CapacityAi:     pulumi.Bool(true),
        		TimeoutSeconds: pulumi.Int(5),
        		Autoscaling: &cpln.WorkloadOptionsAutoscalingArgs{
        			Metric:   pulumi.String("disabled"),
        			Target:   pulumi.Int(95),
        			MinScale: pulumi.Int(1),
        			MaxScale: pulumi.Int(1),
        		},
        	},
        	FirewallSpec: &cpln.WorkloadFirewallSpecArgs{
        		Internal: &cpln.WorkloadFirewallSpecInternalArgs{
        			InboundAllowType: pulumi.String("none"),
        		},
        	},
        })
        if err != nil {
        	return err
        }
        _ = api
        ```
      </Tab>

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

        ```csharp theme={null}
        // The quickstart API: internal only
        var api = new Workload("api", new WorkloadArgs
        {
            Gvc = gvc.Name,
            Name = "api",
            Type = "standard",
            Containers = new[]
            {
                new WorkloadContainerArgs
                {
                    Name = "api",
                    Image = "/org/my-org/image/api:1.0",
                    Cpu = "50m",
                    Memory = "128Mi",
                    Ports = new[]
                    {
                        new WorkloadContainerPortArgs
                        {
                            Protocol = "http",
                            Number = 8080
                        }
                    }
                }
            },
            Options = new WorkloadOptionsArgs
            {
                CapacityAi = true,
                TimeoutSeconds = 5,
                Autoscaling = new WorkloadOptionsAutoscalingArgs
                {
                    Metric = "disabled",
                    Target = 95,
                    MinScale = 1,
                    MaxScale = 1
                }
            },
            FirewallSpec = new WorkloadFirewallSpecArgs
            {
                Internal = new WorkloadFirewallSpecInternalArgs
                {
                    InboundAllowType = "none"
                }
            }
        });
        ```
      </Tab>
    </Tabs>

    <Note>
      Replace `my-org` with your org name; Pulumi needs the full image path.
    </Note>

    ## Step 4: Point the frontend at the API

    Add the environment variable to the `frontend` container:

    <Tabs>
      <Tab title="TypeScript">
        ```typescript theme={null}
              env: {
                API_URL: "http://api.quickstart-gvc.cpln.local:8080",
              },
        ```
      </Tab>

      <Tab title="Python">
        ```python theme={null}
                env={
                    "API_URL": "http://api.quickstart-gvc.cpln.local:8080",
                },
        ```
      </Tab>

      <Tab title="Go">
        ```go theme={null}
        			Env: pulumi.StringMap{
        				"API_URL": pulumi.String("http://api.quickstart-gvc.cpln.local:8080"),
        			},
        ```
      </Tab>

      <Tab title="C#">
        ```csharp theme={null}
                    Env =
                    {
                        { "API_URL", "http://api.quickstart-gvc.cpln.local:8080" }
                    }
        ```
      </Tab>
    </Tabs>

    Deploy both changes:

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

    ## Step 5: See the default deny

    Wait until `frontend` reports `Ready` again, then open its endpoint from the `frontend_endpoint` output. The page waits for its call to the API to time out, then reports below the waitlist form: `Could not reach the API at http://api.quickstart-gvc.cpln.local:8080: no answer within 3 seconds.` The name resolves and the connection is never answered, because `api` admits no caller yet.

    ## Step 6: Allow the frontend

    Name `frontend` on the API's workload list:

    <Tabs>
      <Tab title="TypeScript">
        ```typescript theme={null}
          firewallSpec: {
            internal: {
              inboundAllowType: "workload-list",
              inboundAllowWorkloads: [frontend.selfLink],
            },
          },
        ```
      </Tab>

      <Tab title="Python">
        ```python theme={null}
            firewall_spec=cpln.WorkloadFirewallSpecArgs(
                internal=cpln.WorkloadFirewallSpecInternalArgs(
                    inbound_allow_type="workload-list",
                    inbound_allow_workloads=[frontend.self_link],
                ),
            ))
        ```
      </Tab>

      <Tab title="Go">
        ```go theme={null}
        	FirewallSpec: &cpln.WorkloadFirewallSpecArgs{
        		Internal: &cpln.WorkloadFirewallSpecInternalArgs{
        			InboundAllowType:      pulumi.String("workload-list"),
        			InboundAllowWorkloads: pulumi.StringArray{frontend.SelfLink},
        		},
        	},
        ```
      </Tab>

      <Tab title="C#">
        ```csharp theme={null}
            FirewallSpec = new WorkloadFirewallSpecArgs
            {
                Internal = new WorkloadFirewallSpecInternalArgs
                {
                    InboundAllowType = "workload-list",
                    InboundAllowWorkloads = { frontend.SelfLink }
                }
            }
        ```
      </Tab>
    </Tabs>

    Deploy the change:

    ```bash theme={null}
    pulumi up
    ```
  </Tab>

  <Tab title="AI Agent" icon="sparkles">
    ## Step 3: Create the API workload

    The first prompt names your org and the GVC, so it works in a new conversation too. Replace `my-org` with your org name:

    ```text theme={null}
    Using org "my-org" and GVC "quickstart-gvc", create a workload
    called "api" from the image //image/api:1.0 on port 8080, with
    one replica per location and the container named "api". Keep
    it off the internet and leave its internal firewall closed for
    now. Keep everything else at its default.
    ```

    The agent creates the workload with no public endpoint and its internal firewall at `none`, then follows its deployments until both locations report ready. Nothing reaches it yet.

    ## Step 4: Point the frontend at the API

    ```text theme={null}
    Set the environment variable API_URL on the frontend workload
    to http://api.quickstart-gvc.cpln.local:8080.
    ```

    The agent reads the workload, adds the variable to its container, and follows the rollout until both locations report ready.

    ## Step 5: See the default deny

    Once `frontend` reports `Ready` again, open its canonical endpoint, or ask the agent for it. The page waits for its call to the API to time out, then reports below the waitlist form: `Could not reach the API at http://api.quickstart-gvc.cpln.local:8080: no answer within 3 seconds.` The name resolves and the connection is never answered, because `api` admits no caller yet.

    ## Step 6: Allow the frontend

    ```text theme={null}
    Let only the frontend workload reach the API through its
    internal firewall.
    ```

    The agent reads the API's firewall, switches its internal inbound type to a workload list that names `frontend`, and follows the rollout until both locations report ready.
  </Tab>
</Tabs>

## Verify

Once `api` reports `Ready` again, reload the frontend (a `503` from the API means the mesh is still switching to the new version, so reload once more). The form is enabled and the note reads `Be the first on the list.` Join with an email address. The form answers `You're on the list as you@example.com.`, the note becomes `1 person is already waiting.`, and `Recent signups` lists the address, with the badge next to its heading reading, for the location nearest to you, `Stored in memory by api in aws-us-west-2`.

Open the other location's endpoint from the `Deployments` page (or `cpln workload get-deployments frontend --gvc quickstart-gvc`, or by asking your AI agent for it). It still reads `Be the first on the list.` with `No signups yet.`, because the frontend's call stays in its own location and each API [replica](/concepts/replica) keeps its own memory. Part 4 gives them one database.

<Check>
  Two workloads communicate over the internal network, encrypted with mTLS, with access granted by the receiving workload's firewall, and the API has no public endpoint at all.
</Check>

## Internal firewall options

| Value           | Behavior                                                                        |
| --------------- | ------------------------------------------------------------------------------- |
| `none`          | No internal access to this workload                                             |
| `same-gvc`      | Any workload in the same GVC may access it                                      |
| `same-org`      | Any workload in the org may access it                                           |
| `workload-list` | Only the workloads listed in `inboundAllowWorkload` may access it, from any GVC |

<Note>
  The `workload-list` option requires `view` permission on the allowed workloads.
</Note>

## What you've learned

* **Closed by default**: a new workload admits no internal caller until its firewall names one, and without a public endpoint it is invisible from the internet.
* **Internal endpoints** use the `.cpln.local` domain, resolve from every workload in the org, and work across GVCs.
* **Calls stay local**: a call to an internal endpoint is served by the replica in the caller's own location whenever one is ready there.
* **One build path for every service**: the API went from source to a running workload with the same `cpln image build` and the same workload creation as the frontend.

## Next steps

<Card title="4. Add a database and wire its secret" icon="database" href="/quickstart/database-and-secrets" horizontal>
  Give the API a PostgreSQL database from the Template Catalog, with its credentials delivered through an identity, a policy, and a secret reference.
</Card>

## Clean up

To remove everything the series has created so far:

<Tabs>
  <Tab title="Console" icon="display">
    <Steps>
      <Step title="Delete the GVC">
        Open `quickstart-gvc`, click `Actions`, then `Delete`, type the GVC name to confirm, and click `Delete`. `web`, `frontend`, and `api` go with it.
      </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 gvc delete quickstart-gvc
    cpln image delete frontend:1.0
    cpln image delete frontend:1.1
    cpln image delete api:1.0
    ```
  </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 GVC "quickstart-gvc" and the images frontend:1.0,
    frontend:1.1, and api:1.0.
    ```

    The agent lists what goes: the GVC with `web`, `frontend`, and `api`, and the images. Confirm, and it deletes them.
  </Tab>
</Tabs>

<Note>
  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>
