Tarsana docsDashboard

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.

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

2. Sign up

Start the signup with signup. You do not need to sign in for this.

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:

{
  "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 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:

export SIGNUP=c7e7d896876644cf
export SIGNUP_PROOF=paste-the-signup_proof-value-from-the-link
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:

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

4. Sign in

Exchange your login name and secret for a session with login. A session lasts one hour.

export LOGIN=$EMAIL
export SECRET=paste-your-login-secret
curl -X POST https://api.tarsana.io/v1/sessions \
  -H "Content-Type: application/json" \
  --data "{\"login\": \"$LOGIN\", \"secret\": \"$SECRET\"}"
{
  "session": "YOUR-SESSION",
  "login": "you@example.com",
  "expires_at": "2026-10-05T03:15:39Z"
}
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:

curl -X POST https://api.tarsana.io/v1/access-tokens \
  -H "X-Tarsana-Session: $SESSION" \
  -H "Content-Type: application/json" \
  --data '{"token_label": "quickstart"}'
{
  "access_token": "YOUR-ACCESS-TOKEN",
  "token_id": "67e94a06c977c24c",
  "expires_at": "2027-01-03T02:15:39Z"
}
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. Keep it in now: Tarsana keeps the placement of the first submission of a spec.

{
  "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 runs every check that a submission runs, and stores nothing:

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:

{
  "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 stores the spec and starts the build:

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
{
  "submission": "sha256:cdff062988d4722b2ff052c97ebefb8b571858c493ce47b159df73b1c88809b1",
  "state": "accepted",
  "created": true,
  "pipeline": {
    "job": "j-bbdde9adc372c296",
    "state": "dispatched",
    "created": true,
    "what": "A sentence about the build's progress."
  }
}
export SUBMISSION=sha256:paste-the-rest-of-the-submission-value

9. Follow the build

Ask for the status until pipeline.state is succeeded. A build can take several minutes.

curl "https://api.tarsana.io/v1/tenants/$TENANT/submissions/$SUBMISSION" \
  -H "X-Tarsana-Access-Token: $ACCESS_TOKEN" \
  -H "X-Tarsana-Tenant: $TENANT"
{
  "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 the list of outputs: the image, its SBOM and its signed provenance, each with its SHA-256 digest.

curl "https://api.tarsana.io/v1/tenants/$TENANT/submissions/$SUBMISSION/artifacts" \
  -H "X-Tarsana-Access-Token: $ACCESS_TOKEN" \
  -H "X-Tarsana-Tenant: $TENANT"
{
  "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 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. Each operation above is a tool with the same name and the same arguments. For example, step 8 is:

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

Next steps

View this page as Markdown