# Your First Module

This tutorial writes a small machine-role module from scratch, lints it, packages it as the OCI image CAPTF runs, lints that image, and references it from a `TerraformMachineTemplate`. It ends with a working, lint-clean module image; it does not provision real infrastructure, since the module in this tutorial creates no cloud resources. For the full set of rules a module must follow, see the [module contract](<https://captf.io/docs/module-author/contract/v1alpha1/machine/index.md>) and the [image contract](<https://captf.io/docs/module-author/image-contract/index.md>).

> [!NOTE]
>
> **Before you begin**
>
> - `tfcapi-lint`, installed as in [tfcapi-lint](<https://captf.io/docs/module-author/tfcapi-lint/#install>).
> - `podman` or `docker`, to build the image.
> - A directory to work in. This tutorial calls it `machine/`.

Terraform or OpenTofu itself is not required on your machine: the reference Containerfile below downloads it inside the build, and `tfcapi-lint module` parses the module’s files directly.

## 1\. Write the module

A machine-role module implements one Terraform/OpenTofu module that CAPTF calls once per `TerraformMachine`. It receives the [common contract inputs](<https://captf.io/docs/module-author/contract/v1alpha1/common/index.md>) plus the [machine role’s inputs](<https://captf.io/docs/module-author/contract/v1alpha1/machine/#inputs>), and must return the [machine role’s outputs](<https://captf.io/docs/module-author/contract/v1alpha1/machine/#outputs>) plus the common `health` output. This tutorial’s module returns fixed values instead of calling a cloud provider, which keeps every input and output visible and needs no credentials or provider plugins to build or lint. Swap the fixed values for calls to your cloud’s Terraform/OpenTofu provider once you understand the shape.

Create four files in `machine/`.

### `variables.tf`

The contract inputs. `captf_contract`, `captf_cluster`, `captf_object` and `captf_tags` apply to every role; `captf_cluster_outputs` applies to the machine and machinepool roles only (the cluster role produces it, and so does not receive it); the rest are specific to the machine role.

variables.tf

```hcl
# Contract inputs of the machine role, v1alpha1 (https://captf.io/docs/module-author/contract/v1alpha1/common.html
# and machine.html).

variable "captf_contract" {
  type = string
}

variable "captf_cluster" {
  type = object({
    name      = string
    namespace = string
  })
}

variable "captf_object" {
  type = object({
    kind      = string
    name      = string
    namespace = string
  })
}

# The cluster module's exports. The controller always sets it; the default
# follows the contract skeleton (machine.md).
variable "captf_cluster_outputs" {
  type    = any
  default = null
}

variable "captf_tags" {
  type = map(string)
}

variable "machine_name" {
  type = string
}

# Base64 of the bootstrap Secret's value.
variable "bootstrap_data" {
  type      = string
  sensitive = true
}

variable "bootstrap_format" {
  type = string
}

variable "failure_domain" {
  type    = string
  default = null
}

variable "kubernetes_version" {
  type    = string
  default = null
}

variable "control_plane" {
  type = bool
}
```

### `main.tf`

The module’s own logic. This tutorial stands in a `terraform_data` resource for a real instance, so the module has something to hold its inputs; a real module replaces this with the resources that create an instance.

main.tf

```hcl
# No-op machine module: implements the v1alpha1 machine role with no cloud.

# The stand-in for an instance. user_data decodes bootstrap_data the way a
# module feeding a plain user-data argument would, which proves the
# controller's base64 encoding round-trips (the value stays sensitive).
resource "terraform_data" "instance" {
  input = {
    cluster            = var.captf_cluster
    object             = var.captf_object
    machine_name       = var.machine_name
    tags               = var.captf_tags
    backend_id         = try(var.captf_cluster_outputs.backend_id, null)
    user_data          = base64decode(var.bootstrap_data)
    bootstrap_format   = var.bootstrap_format
    failure_domain     = var.failure_domain
    kubernetes_version = var.kubernetes_version
    control_plane      = var.control_plane
  }
}
```

### `outputs.tf`

The contract outputs. `provider_id`, `addresses` and `failure_domain` are required; `interruptible` must be declared even when it is always `false`; `health` is the common output every role returns.

outputs.tf

```hcl
# Contract outputs of the machine role, v1alpha1.

# Stable per Machine. With no Node behind it, the e2e suite never expects a
# nodeRef; a real module emits the CCM's or kubelet's format (machine.md).
output "provider_id" {
  value = "noop:///${var.captf_object.namespace}/${var.machine_name}"
}

output "addresses" {
  value = [{ type = "InternalIP", address = "10.0.0.1" }]
}

output "failure_domain" {
  value = var.failure_domain
}

output "interruptible" {
  value = false
}

output "health" {
  value = { state = "running", healthy = true, message = null, reasons = [] }
}
```

### `versions.tf`

versions.tf

```hcl
terraform {
  # terraform_data needs Terraform 1.4; the variants' plantimestamp() needs
  # 1.5. Every OpenTofu release (1.6+) has both.
  required_version = ">= 1.5"
}
```

This module declares no `required_providers`: `terraform_data` ships with Terraform/OpenTofu itself, so there is no provider to install or mirror. A module that calls a cloud provider adds a `required_providers` block here as usual.

## 2\. Lint the module

```sh
tfcapi-lint module ./machine --role machine --strict
```

A clean module prints an empty finding list and an all-zero summary.

> [!NOTE]
>
> **`--strict` fails on warnings too**
>
> `--strict` fails the command on a warning as well as an error, so a lint-clean module here stays lint-clean once you add real inputs, outputs and user variables.

See [tfcapi-lint](<https://captf.io/docs/module-author/tfcapi-lint/index.md>) for the full command reference and [tfcapi-lint CLI](<https://captf.io/docs/reference/tfcapi-lint-cli/index.md>) for every check ID.

## 3\. Package it as an image

CAPTF runs a module as one OCI image that bundles the module’s files and the Terraform or OpenTofu binary; there is no separate module source and no separate runtime image. Save the reference Terraform-based Containerfile into `machine/`:

Containerfile.terraform

```dockerfile
# Reference source image, Terraform base (docs/book/src/module-author/image-contract.md "Reference:
# Terraform base"). Run from your module's root directory:
#
#   podman build -f Containerfile.terraform --build-arg ROLE=cluster \
#     --build-arg IMAGE_SOURCE=https://github.com/<org>/<repo> \
#     --build-arg IMAGE_REVISION="$(git rev-parse HEAD)" \
#     --build-arg IMAGE_VERSION=<tag> -t <registry>/<repo>:<tag> .
#
# The image tag is the module version. Lint first:
#   tfcapi-lint module . --role cluster --strict

ARG RUNTIME_VERSION=1.16.4
# The org.opencontainers.image.* labels below (image-contract.md "OCI
# labels"): leave these unset for a local/test build, or pass them from
# your CI pipeline (source repo URL, commit SHA, the image tag).
ARG IMAGE_SOURCE=""
ARG IMAGE_REVISION=""
ARG IMAGE_VERSION=""

# Optional but recommended: hermetic provider mirror for the platforms you
# publish. Needs registry egress at build time; drop this stage (and the
# COPY --from=mirror below) for a non-hermetic image.
FROM docker.io/hashicorp/terraform:${RUNTIME_VERSION} AS mirror
WORKDIR /src
COPY . /src
# get: `providers mirror` refuses a module whose nested local modules are not
# installed; get installs them (no providers), in this stage only.
# mkdir: `providers mirror` does not create the target when the module
# requires no providers, and the COPY --from=mirror below needs it.
RUN terraform get \
 && mkdir -p /captf/providers \
 && terraform providers mirror -platform=linux_amd64 -platform=linux_arm64 /captf/providers

FROM docker.io/hashicorp/terraform:${RUNTIME_VERSION}
ARG ROLE=cluster
ARG RUNTIME_VERSION
ARG IMAGE_SOURCE
ARG IMAGE_REVISION
ARG IMAGE_VERSION
COPY --from=mirror /captf/providers /captf/providers
COPY . /captf/module
RUN ln -s /bin/terraform /captf/runtime \
 && adduser -D -u 65532 captf \
 && chown -R 65532:65532 /captf
USER 65532
LABEL io.captf.contract="v1alpha1" \
      io.captf.role="${ROLE}" \
      io.captf.runtime="terraform" \
      io.captf.runtime.version="${RUNTIME_VERSION}" \
      org.opencontainers.image.source="${IMAGE_SOURCE}" \
      org.opencontainers.image.revision="${IMAGE_REVISION}" \
      org.opencontainers.image.version="${IMAGE_VERSION}"
```

> [!TIP]
>
> **OpenTofu works too**
>
> An OpenTofu-based equivalent is also available ([`Containerfile.opentofu`](<https://captf.io/docs/module-author/image-contract/#reference-opentofu-base>)); either runtime satisfies the contract.

Build from inside `machine/`:

```sh
export IMAGE=registry.example.com/acme/machine:v1.0.0
podman build -f Containerfile.terraform --build-arg ROLE=machine -t "$IMAGE" .
```

`IMAGE` is the registry, repository and tag you push to; `ROLE=machine` tags the image with the role it implements. See the [image contract](<https://captf.io/docs/module-author/image-contract/index.md>) for the fixed paths, OCI labels and execution environment every image must satisfy.

## 4\. Lint the image

`tfcapi-lint image` reads a pushed registry reference or a local OCI layout. Without a registry to push to yet, save the image podman just built to a local OCI directory and lint that:

```sh
podman save --format oci-dir -o /tmp/first-module-image "$IMAGE"
tfcapi-lint image --role machine "oci:/tmp/first-module-image"
```

Once you push `$IMAGE` to a registry, lint the pushed reference the same way: `tfcapi-lint image --role machine "$IMAGE"`.

## 5\. Reference it from a TerraformMachineTemplate

A `TerraformMachineTemplate` is what a `MachineDeployment`, a `KubeadmControlPlane` or a `MachineSet` points at to create `TerraformMachine`s; its `spec.template.spec.source.image` names the image you just built:

first-module-template.yaml

```yaml
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: TerraformMachineTemplate
metadata:
  name: first-module
  namespace: default
spec:
  template:
    spec:
      source:
        image: registry.example.com/acme/machine:v1.0.0
```

Apply it:

```sh
kubectl apply -f first-module-template.yaml
```

`kubectl get terraformmachinetemplate first-module -n default` confirms the object exists; there is nothing to provision yet, since nothing references this template as a `MachineDeployment`, `MachineSet` or control plane’s `infrastructureRef` does. A `TerraformMachine` created from it needs an identity to run under: either its own `spec.identityRef`, or one inherited from its `TerraformCluster`’s `spec.defaults.identityRef`, as set up in the [quick start](<https://captf.io/docs/getting-started/quick-start/#2-apply-an-identity>).

## Next steps

- Walk through the [quick start](<https://captf.io/docs/getting-started/quick-start/index.md>) to wire a machine template like this one into a full cluster.
- Read the [module contract](<https://captf.io/docs/module-author/contract/v1alpha1/index.md>) for every rule a production module must follow, and the [cluster role](<https://captf.io/docs/module-author/contract/v1alpha1/cluster/index.md>) and [machine pool role](<https://captf.io/docs/module-author/contract/v1alpha1/machinepool/index.md>) for the other two roles a full deployment needs.
- Read [Runtime Environment](<https://captf.io/docs/module-author/runtime-environment/index.md>) for what a module sees when the runner actually executes it.
