# Build your first image

In this quickstart you create your Tarsana account, get an API access token, and build a hardened Ubuntu image from a short spec. It takes about ten minutes, most of it waiting for the build.

You need `curl` and a terminal. The examples use `jq` to read answers; you can also read them by eye.

## 1. Choose your tenant ID

Your tenant ID names your space in Tarsana. Use lower-case letters, digits, dots, dashes and underscores, up to 64 characters. You cannot change it later, and it should not be your email address. See [tenant](/concepts#tenant).

```sh
export TENANT=acme
export EMAIL=you@example.com
```

## 2. Sign up

Start the signup with [`signup`](/api/signup). You do not need to sign in for this.

```sh
curl -X POST https://api.tarsana.io/v1/signups \
  -H "Content-Type: application/json" \
  --data "{\"identity_provider\": \"email\", \"tenant\": \"$TENANT\", \"email\": \"$EMAIL\"}"
```

If it worked, you see:

```json
{
  "signup": "c7e7d896876644cf",
  "tenant": "acme",
  "sent_to": "you@example.com",
  "expires_at": "2026-10-05T02:30:39Z"
}
```

Tarsana sends a link to your email address. If the tenant ID is taken, you get [`tenant_taken`](/api/errors#tenant_taken) now, before any email is sent; choose another ID and try again.

## 3. Finish the signup

The link in the email has two values in it: `signup` and `signup_proof`. Open the link in your browser to finish in the dashboard, or send both values to [`complete-signup`](/api/complete-signup):

```sh
export SIGNUP=c7e7d896876644cf
export SIGNUP_PROOF=paste-the-signup_proof-value-from-the-link
```

```sh
curl -X POST https://api.tarsana.io/v1/signups/complete \
  -H "Content-Type: application/json" \
  --data "{\"signup\": \"$SIGNUP\", \"signup_proof\": \"$SIGNUP_PROOF\"}"
```

If it worked, you see your login name and your login secret:

```json
{
  "login": "you@example.com",
  "secret": "YOUR-LOGIN-SECRET",
  "tenant": "acme",
  "scopes": [
    "operate",
    "read",
    "submit"
  ]
}
```

> **Save the secret now.** It is shown only once. The link works once, for 15 minutes; if it expired, you get [`signup_refused`](/api/errors#signup_refused) and you start again at step 2.

## 4. Sign in

Exchange your login name and secret for a session with [`login`](/api/login). A session lasts one hour.

```sh
export LOGIN=$EMAIL
export SECRET=paste-your-login-secret
```

```sh
curl -X POST https://api.tarsana.io/v1/sessions \
  -H "Content-Type: application/json" \
  --data "{\"login\": \"$LOGIN\", \"secret\": \"$SECRET\"}"
```

```json
{
  "session": "YOUR-SESSION",
  "login": "you@example.com",
  "expires_at": "2026-10-05T03:15:39Z"
}
```

```sh
export SESSION=paste-the-session-value
```

## 5. Create an API access token

Scripts and MCP clients use a long-lived access token instead of a session. Create one with [`issue-access-token`](/api/issue-access-token):

```sh
curl -X POST https://api.tarsana.io/v1/access-tokens \
  -H "X-Tarsana-Session: $SESSION" \
  -H "Content-Type: application/json" \
  --data '{"token_label": "quickstart"}'
```

```json
{
  "access_token": "YOUR-ACCESS-TOKEN",
  "token_id": "67e94a06c977c24c",
  "expires_at": "2027-01-03T02:15:39Z"
}
```

> **Save the access token now.** It is shown only once and lasts 90 days. Every request from here on sends it in the `X-Tarsana-Access-Token` header, together with your tenant ID in `X-Tarsana-Tenant`.

```sh
export ACCESS_TOKEN=paste-the-access_token-value
```

## 6. Write a spec

Save this as `spec.json`. It asks for Ubuntu 24.04 (`noble`) from a fixed package snapshot, with the baseline hardening profile and nothing else installed. Put your own tenant ID in `metadata.tenant`.

The `placement` says where servers made from this spec will run: here a Hetzner Cloud `cpx12` in Nuremberg. It does not change the image, and you need it only when you [run the image on your provider](/quickstarts/run-on-your-provider). Keep it in now: Tarsana keeps the placement of the first submission of a spec.

```json
{
  "schema_version": "2.0",
  "metadata": {"name": "web", "tenant": "acme"},
  "distro": "ubuntu",
  "snapshot_id": "20260715T000000Z",
  "base_ref": "noble",
  "hardening_profile": "tarsana-baseline",
  "policy_class": "baseline",
  "placement": {"provider": "hetzner-cloud", "server_type": "cpx12", "region": "nbg1"}
}
```

## 7. Check the spec

[`validate`](/api/validate) runs every check that a submission runs, and stores nothing:

```sh
curl -X POST "https://api.tarsana.io/v1/tenants/$TENANT/specs/validate" \
  -H "X-Tarsana-Access-Token: $ACCESS_TOKEN" \
  -H "X-Tarsana-Tenant: $TENANT" \
  -H "Content-Type: application/json" \
  --data @spec.json
```

If the spec is good, `valid` is `true` and every gate says `pass`:

```json
{
  "valid": true,
  "spec_hash": "sha256:cdff062988d4722b2ff052c97ebefb8b571858c493ce47b159df73b1c88809b1",
  "problems": []
}
```

If `valid` is `false`, `problems` lists what to fix, each with the path in your spec where it is.

## 8. Submit it

[`submit`](/api/submit) stores the spec and starts the build:

```sh
curl -X POST "https://api.tarsana.io/v1/tenants/$TENANT/specs" \
  -H "X-Tarsana-Access-Token: $ACCESS_TOKEN" \
  -H "X-Tarsana-Tenant: $TENANT" \
  -H "Content-Type: application/json" \
  --data @spec.json
```

```json
{
  "submission": "sha256:cdff062988d4722b2ff052c97ebefb8b571858c493ce47b159df73b1c88809b1",
  "state": "accepted",
  "created": true,
  "pipeline": {
    "job": "j-bbdde9adc372c296",
    "state": "dispatched",
    "created": true,
    "what": "A sentence about the build's progress."
  }
}
```

> **Save the submission ID** for the next steps. It is the spec's hash, so submitting the same spec again returns the same submission and does not start a second build.

```sh
export SUBMISSION=sha256:paste-the-rest-of-the-submission-value
```

## 9. Follow the build

Ask for the [`status`](/api/status) until `pipeline.state` is `succeeded`. A build can take several minutes.

```sh
curl "https://api.tarsana.io/v1/tenants/$TENANT/submissions/$SUBMISSION" \
  -H "X-Tarsana-Access-Token: $ACCESS_TOKEN" \
  -H "X-Tarsana-Tenant: $TENANT"
```

```json
{
  "state": "accepted",
  "pipeline": {
    "job": "j-bbdde9adc372c296",
    "state": "dispatched",
    "created": true,
    "what": "A sentence about the build's progress."
  },
  "artifacts": [
    "artifacts",
    "provenance",
    "sbom",
    "spec"
  ]
}
```

`pipeline.state` moves from `dispatched` to `pending` to `succeeded`. If it ends in `failed` or `rejected`, `pipeline.what` says why.

## 10. See what the build produced

When the build has succeeded, [`fetch`](/api/fetch) the list of outputs: the image, its SBOM and its signed provenance, each with its SHA-256 digest.

```sh
curl "https://api.tarsana.io/v1/tenants/$TENANT/submissions/$SUBMISSION/artifacts" \
  -H "X-Tarsana-Access-Token: $ACCESS_TOKEN" \
  -H "X-Tarsana-Tenant: $TENANT"
```

```json
{
  "artifact": "artifacts",
  "content": {
    "schema": "tarsana.submission-artifacts/v1",
    "job": "j-bbdde9adc372c296",
    "published_at": "2026-10-05T03:01:12Z",
    "image": {
      "format": "raw-verity",
      "digest": "sha256:0f1e2d3c...",
      "size_bytes": 734003200,
      "verity_roothash": "9c4b..."
    },
    "sbom": {
      "format": "CycloneDX",
      "digest": "sha256:fb917071...",
      "bytes": 48213
    },
    "provenance": {
      "format": "in-toto-dsse",
      "digest": "sha256:eeee...",
      "bytes": 1161
    },
    "release": {
      "version": "20261005-030112",
      "plain_sha256": "9487214a...",
      "plain_size": 2147483648
    }
  }
}
```

If you ask too early, you get [`no_such_artifact`](/api/errors#no_such_artifact) with the state of the build; ask again later.

## Do the same from an AI assistant

Connect your MCP client as described on the [MCP page](/mcp). Each operation above is a tool with the same name and the same arguments. For example, step 8 is:

```json
{
  "method": "tools/call",
  "params": {
    "name": "submit",
    "arguments": {
      "tenant": "acme",
      "body": {
        "...": "the contents of spec.json"
      }
    }
  }
}
```

## Next steps

- [Run it on your provider](/quickstarts/run-on-your-provider): create a server from this spec in your Hetzner Cloud project.
- [Verify a signature yourself](/quickstarts/verify-a-signature): check the image without trusting Tarsana.
- [API reference](/api): everything else you can do.
