> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Subnets

> Carving a VPC into ranges — what public actually means, which addresses you do not get, and what blocks a delete.

<Tabs>
  <Tab title="Console">
    Go to **Networking → Subnets** and choose **Create Subnet**. Under
    **Subnet details**, pick the **VPC**, then fill in **Name** and **CIDR**.
    **Gateway IP** is optional and defaults for you.

    Under **Routing**, **Route Table** starts on the VPC's default table. This is
    the one field worth deciding here rather than later — see
    [public and private are routing](#public-and-private-are-routing-not-a-flag).
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/subnets
    {
      "vpc": "5f8d3a2e-1c4b-4e7a-9f6d-2b1a8c3e5d7f",
      "name": "prod-web",
      "cidr_ipv4": "10.0.1.0/24"
    }
    ```
  </Tab>

  <Tab title="CLI">
    ```bash theme={null}
    basaltic network subnet create --vpc <vpc-id> \
      --name prod-web --cidr-ipv4 10.0.1.0/24
    ```

    Add `--cidr-ipv6` for a dual-stack subnet, and `--route-table` to land it
    somewhere other than the default table.
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    sub, err := network.New(cfg).CreateSubnet(ctx, &network.SubnetCreateRequest{
        VPC: vpcID,
        Name:  "prod-web",
        CIDRIPv4:  "10.0.1.0/24",
    })
    ```
  </Tab>
</Tabs>

The `vpc` input accepts a UUID, CRN, or exact account-scoped name. The optional
`route_table` accepts a UUID, a nested CRN, or a name within that VPC; omitting it
selects the default table, named `<vpc-name>-private-rt`. An empty reference is invalid. A subnet CRN includes its parent:
`crn:network:sa-saopaulo-1:my-account:vpc/prod/subnet/public`.

Lists accept exact `name` and `crn` filters. Filtering subnets by name also
requires `vpc`; filtering interfaces by name requires `subnet`. Filters combine,
and a failed lookup never tries a different interpretation of the reference.

## Reading placement

Subnet responses include the full parent VPC in `vpc` and a `route_table`
summary containing `id`, `crn` and `name`. Use `vpc.id` and `vpc.name` for
placement, and `route_table.id` and `route_table.name` for routing links;
separate reads are unnecessary just to display those names. These embeds
replace the former `vpc_id` and `route_table_id` response fields.

`route_table` can be null when its lookup no longer resolves, such as during
concurrent reassociation and deletion of the former table. Treat that as
unavailable placement: refresh before acting on the association. It does not
mean the subnet is private or uses the default table. The VPC relationship is required.

Requests still use string references. To reuse an embedded relationship in a
create or update request, pass its `id` (or a supported CRN/name), not the
embedded object. In Go, check the nullable summary before reading its ID:

```go theme={null}
if sub.RouteTable != nil {
    fmt.Println(sub.RouteTable.ID, sub.RouteTable.Name)
}
```

The CIDR has to sit fully inside the parent VPC's CIDR of the same family, and
must not overlap another subnet in the same VPC. `name` is unique per VPC.

`gateway_ipv4` defaults to the first address after the network address
(`10.0.1.1` on a `10.0.1.0/24`). You can supply your own, as long as it is
inside the CIDR.

`cidr_ipv4` and `gateway_ipv4` are immutable. `description`, `tags` and
`route_table` are not.

## Public and private are routing, not a flag

There is no `public` boolean on a subnet. A subnet is public when the route
table it uses has a `0.0.0.0/0` route pointing at an internet gateway, and
private otherwise. The subnet's `route_table.id` identifies that table, so moving a
subnet between the two postures is a `PATCH` that re-associates it with a
different table:

<Tabs>
  <Tab title="Console">
    Open the subnet's **Settings** tab. In **Routing**, pick a table and
    choose **Change route table**. Refresh if the current association is
    unavailable.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    PATCH /v1/subnets/{subnet_id}
    { "route_table": "a3c9e1f4-7b2d-4a6e-8c1f-9d3b5e7a2c4f" }
    ```
  </Tab>

  <Tab title="CLI">
    ```bash theme={null}
    basaltic network subnet update <subnet-id> --route-table private
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    sub, err := network.New(cfg).UpdateSubnet(ctx, subnetID, &network.SubnetUpdateRequest{
        RouteTable: basaltic.String("private"),
    })
    ```
  </Tab>
</Tabs>

<Warning>
  Moving a subnet changes the posture of **every instance already running in
  it**, immediately — not only ones created afterwards. Moving a subnet onto a
  table with a default route to an internet gateway is what makes it public;
  moving it off is what takes that away.
</Warning>

Everything about which routes exist lives on the
[routing](/networking/routing) page.

## Which addresses you actually get

Three addresses in an IPv4 subnet are unavailable: the network address, the
broadcast address, and the gateway. A `/24` leaves 253 usable — nothing is held
back for DNS or instance metadata, because the metadata endpoint is the
link-local `169.254.169.254` rather than an address inside your subnet.

Auto-allocation takes the lowest free address above the gateway. If you supply
an IPv4 `address` in an interface's `addresses` request it is checked against all of it: inside the CIDR,
not the network or broadcast address, and not already taken — either by another
interface or held by another resource, such as a load balancer's VIP.

## Dual-stack subnets

The VPC must own an IPv6 prefix. Each dual-stack subnet uses one `/64`; a
public `/60` provides sixteen, while larger private ULA allocations provide more.

<Tabs>
  <Tab title="Console">
    On **Create Subnet**, select a VPC with IPv6. Under **IPv6**, choose
    **Automatic IPv6 /64** to assign a free prefix. The form starts on
    **IPv4 only**. To choose a specific prefix, select **Manual IPv6 /64**
    and enter **IPv6 CIDR**.

    IPv6 is disabled when the selected VPC has no IPv6 prefix. Changing the
    VPC or region clears the IPv6 selection.
  </Tab>

  <Tab title="API">
    Request automatic assignment without naming a prefix:

    ```bash theme={null}
    POST /v1/subnets
    {
      "vpc": "5f8d3a2e-1c4b-4e7a-9f6d-2b1a8c3e5d7f",
      "name": "prod-web",
      "cidr_ipv4": "10.0.1.0/24",
      "allocate_cidr_ipv6": true
    }
    ```

    To choose a specific `/64`, replace `"allocate_cidr_ipv6": true` with
    `"cidr_ipv6": "2a13:9500:1a6:100::/64"`, using a free prefix inside your
    VPC's `cidr_ipv6`. Do not send both. Omit both for an IPv4-only subnet.
  </Tab>

  <Tab title="CLI">
    Name a free `/64` inside your VPC:

    ```bash theme={null}
    basaltic network subnet create --vpc <vpc-id> \
      --name prod-web --cidr-ipv4 10.0.1.0/24 --cidr-ipv6 2a13:9500:1a6:100::/64
    ```

    For automatic assignment, replace `--cidr-ipv6` and its value with
    `--allocate-cidr-ipv6`.
  </Tab>

  <Tab title="Go">
    Name a free `/64` inside your VPC:

    ```go theme={null}
    cidrV6 := "2a13:9500:1a6:100::/64"
    sub, err := network.New(cfg).CreateSubnet(ctx, &network.SubnetCreateRequest{
        VPC:  vpcID,
        Name:   "prod-web",
        CIDRIPv4:   "10.0.1.0/24",
        CIDRIPv6: &cidrV6,
    })
    ```

    For automatic assignment, replace `CIDRIPv6` with
    `AllocateCIDRIPv6: basaltic.Bool(true)`.
  </Tab>
</Tabs>

Automatic assignment selects the lowest free `/64`, including prefixes freed
by deleting a subnet. Explicit prefixes must be `/64`s inside the VPC and
cannot overlap another subnet. The IPv6 gateway is the prefix's `::1` address.

When all available prefixes are occupied, creation returns HTTP `409` with
“VPC has no free IPv6 /64s”. Delete an unused subnet or use another VPC.
A `409` mentioning allocation contention means concurrent requests claimed
prefixes during creation; retry the request.

### Enabling IPv6 later

The VPC must already have IPv6. Once a subnet prefix is set, it cannot be
replaced or removed. Enabling it adds IPv6 to every existing interface while
preserving IPv4 addresses. A NAT gateway hosted in that subnet also receives
its public IPv6 address, even if you leave routing unchanged.

<Tabs>
  <Tab title="Console">
    Open the subnet and choose **Enable IPv6**. Leave **IPv6 CIDR (optional)**
    empty for automatic allocation. **Copy matching IPv4 security rules** is
    selected by default. Under **IPv6 internet routing**, keep **Match IPv4
    routing** or choose the required gateway or **Leave routing unchanged**.
    Confirm with **Enable IPv6**.
  </Tab>

  <Tab title="API">
    ```http theme={null}
    PATCH /v1/subnets/{subnet_id}
    ```

    ```json theme={null}
    {
      "allocate_cidr_ipv6": true,
      "copy_ipv4_security_rules": true,
      "ipv6_routing": "match_ipv4"
    }
    ```

    Replace `allocate_cidr_ipv6` with `cidr_ipv6` to choose a specific `/64`.
  </Tab>

  <Tab title="CLI">
    ```bash theme={null}
    basaltic network subnet update <subnet-id> \
      --allocate-cidr-ipv6 --ipv6-routing match_ipv4
    ```

    Add `--copy-ipv4-security-rules=false` to keep security-group rules unchanged.
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    subnet, err := network.New(cfg).UpdateSubnet(ctx, subnetID, &network.SubnetUpdateRequest{
        AllocateCIDRIPv6:      basaltic.Bool(true),
        CopyIPv4SecurityRules: basaltic.Bool(true),
        IPv6Routing:          basaltic.String("match_ipv4"),
    })
    ```
  </Tab>
</Tabs>

Rule copying preserves protocol, ports and direction. It copies `0.0.0.0/0`
to `::/0` and copies security-group references. Restricted IPv4 CIDRs have no
automatic IPv6 equivalent and stay unchanged. Existing equivalent IPv6 rules
are not duplicated. Changes affect every interface sharing those groups.

`match_ipv4` adds `::/0` through the same internet or NAT gateway as the IPv4
default route. Without an IPv4 default route, IPv6 remains isolated. Existing
IPv6 default routes are always preserved. You can also select
`internet_gateway`, `nat_gateway`, `egress_only_gateway`, or `unchanged`.
An IPv6 NAT route requires IPv6 on the NAT gateway's hosting subnet first.
Egress-only gateways provide native GUA egress; ULA internet access needs NAT.
Route changes affect every subnet sharing the table.

Setup checks permissions and quotas before committing changes. Copying rules
requires `network:CreateSecurityGroupRule`; adding routes requires
`network:CreateRoute`; creating an egress-only gateway requires
`network:CreateEgressOnlyGateway`. Upgrading a hosted NAT gateway requires
`network:UpdateNATGateway` and available public IPv6 quota.

`cidr_ipv6` on the response is how a client tells the two apart: it is null on a
v4-only subnet and set on a dual-stack one, alongside `gateway_ipv6`.
New interfaces in a dual-stack subnet get a `/96` allocation automatically,
with its first `/128` supplied through DHCPv6.

<Warning>
  An IPv6 prefix on the subnet and an address on the NIC do **not** make anything
  reachable. IPv6 reachability also depends on routes and security-group
  rules — see
  [IPv6 reachability is the route](/networking/gateways#ipv6-reachability-is-the-route-not-the-address).
</Warning>

## Deleting a subnet

Three things block it, and the error names the one that did:

| Blocker                                | What the error says                                     |
| -------------------------------------- | ------------------------------------------------------- |
| Interfaces still in the subnet         | "subnet still has *N* interface(s) — delete them first" |
| A NAT gateway lives here               | "subnet hosts NAT gateway *name* — delete it first"     |
| An address is held by another resource | names the address and the holder                        |

The third one is the surprising one. A load balancer's VIP is held inside your
subnet without being an interface. The delete refuses rather than dropping the
address underneath the resource that owns it.
