Skip to content

Set up and configureยป

Steps 1โ€“4 of the migration walkthrough: scaffold the workspace, pick a source, set its credentials, and validate the configuration before anything runs.

Before you start โ€” what you needยป

liftoff moves an estate onto Spacelift, so it needs credentials for both ends and an account ready to receive the work. Have these in place before step 1:

  • A Spacelift account, and an API key with root-space admin access. The account is read from the very first step, and a repository and admin stack are created later, so a narrowly scoped key fails partway through.
  • An account on the system you are migrating from, with credentials that can read everything in scope. Each source declares exactly which keys it needs; liftoff sources lists them and step 4 proves them.
  • A VCS integration in Spacelift that can reach the repositories your source uses. Generated stacks name the integration they bind to, so one has to exist for every provider in the estate.
  • At least one worker โ€” a private worker pool with a worker attached, or the public pool. Applying the generated code is itself a Spacelift run, so an account with no worker cannot finish a migration.
  • Network access to both APIs from wherever you run liftoff.
  • A decision about where the generated code will live: the Spacelift-managed repository, which needs no setup, or your own git provider.

liftoff checks the ones it can rather than leaving you to find out later. liftoff configure validate proves both key pairs, and liftoff audit reports a repository it cannot bind to an integration, one whose integration is not connected to the account it lives under, an integration Spacelift cannot reach at all, or an account with nothing to run on โ€” all before you generate anything.

Before you start โ€” install liftoffยป

First, know which version you're meant to run. Most operators want the current stable release, installed just below. But if you were pointed at a release candidate โ€” anything ending -rc, or a specific pre-release version โ€” stop here and jump to Release candidates. The stable brew install below and the RC cask both provide the same liftoff command, so running the stable one now would replace an RC you'd already installed, quietly and with no warning from Homebrew. Confirm the target first; install second.

If stable is what you want, install it:

1
brew install spacelift-solutions/tap/liftoff

Without Homebrew:

1
curl -fsSL https://raw.githubusercontent.com/spacelift-solutions/liftoff/main/install.sh | sh

On Windows, download the zip from the releases page and put liftoff.exe on your PATH.

Either way you get one binary and nothing else โ€” no runtime, no container, no services. Check it answers and prints the version you meant to install before going further โ€” if you were sent to a release candidate, this is where you catch having landed on stable instead:

1
liftoff --version

To upgrade later, brew upgrade liftoff, or re-run the install script.

Release candidatesยป

brew install spacelift-solutions/tap/liftoff is always the current stable release โ€” a candidate can never arrive there by surprise. Candidates are a separate cask you have to ask for by name:

1
brew install spacelift-solutions/tap/liftoff-rc

Both provide the same liftoff command, so Homebrew will not let you have both at once. Switch back with:

1
brew uninstall liftoff-rc && brew install spacelift-solutions/tap/liftoff

Installing a specific versionยป

Homebrew carries the newest build of each line and nothing older, so an exact version comes from the install script:

1
curl -fsSL https://raw.githubusercontent.com/spacelift-solutions/liftoff/main/install.sh | VERSION=v1.3.0 sh

The setting goes on sh, not in front of curl โ€” in front of curl it reaches the download and not the script that reads it.

Pinning is what you want in CI, where "whatever is newest" is not a build you can reproduce. The tags are on the releases page.

INSTALL_DIR puts the binary somewhere other than /usr/local/bin โ€” reach for it when that directory needs root:

1
curl -fsSL https://raw.githubusercontent.com/spacelift-solutions/liftoff/main/install.sh | INSTALL_DIR="$HOME/.local/bin" sh

Step 1 โ€” initialize the workspaceยป

1
liftoff init

Creates ./.liftoff/ in the current directory with an empty config.yaml and the SQLite store.

1
2
3
4
5
6
7
8
Config Created  yes
Config Dir      /tmp/migration-demo/.liftoff
Config Path     /tmp/migration-demo/.liftoff/config.yaml
Db Path         /tmp/migration-demo/.liftoff/liftoff.db

Next
  $ liftoff sources
  $ liftoff configure --source <id> --set source.<key>=value

Running init again on an existing workspace reports Config Created no and changes nothing, so it is safe to repeat. On a workspace created by an older binary it migrates the store schema forward.

The only decision here is where to stand. init creates ./.liftoff under the current directory, one directory per migration. Later commands walk up from $PWD to find that folder, so you can run them from a subdirectory without re-passing the path. --config-dir or LIFTOFF_CONFIG_DIR still override the walk when you need a workspace somewhere else.

One warning, and it grows over the migration: ./.liftoff/ becomes the most sensitive thing on this machine. config.yaml holds your source API token after step 3 (unless you kept it in an env reference), and the store โ€” liftoff.db, a plain, unencrypted SQLite file โ€” holds everything discover pulls, variable values included, and later the captured secret values and full Terraform state blobs the finalize steps push. Treat the directory accordingly. If you are running inside a git repository, ignore the workspace now:

1
echo '.liftoff/' >> .gitignore

That .gitignore line is necessary but not the whole story โ€” it keeps the workspace out of git, not off backups, syncs, or a shared machine, and it does nothing once the migration is done and the directory should simply be gone. What the directory contains and how to dispose of it safely is covered at the end of the walkthrough, in finalize.

Next up, liftoff sources to see what you can migrate from.

Step 2 โ€” pick a sourceยป

1
liftoff sources

Lists every source this binary can migrate from, with the settings each one takes. Read-only; run it as often as you like.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Sources (1)
  Terraform Cloud / Enterprise (id: terraform)
    Config Keys (used in `liftoff discover`) (4)
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ”‚ Label              โ”‚ Key                   โ”‚ Required โ”‚ Default             โ”‚ Help                โ”‚ When Unset โ”‚
      โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
      โ”‚ API endpoint       โ”‚ api_endpoint          โ”‚ โ€“        โ”‚ https://app.terrafo โ”‚ Base URL of the     โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚ rm.io               โ”‚ Terraform API; set  โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ it to reach a self- โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ hosted Terraform    โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ Enterprise          โ”‚            โ”‚
      โ”‚ API token          โ”‚ api_token             โ”‚ โœ“        โ”‚                     โ”‚ Token used to       โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ authenticate with   โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ the Terraform API โ€” โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ must be a user      โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ token from an admin โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ user (team and      โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ organization tokens โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ do not work)        โ”‚            โ”‚
      โ”‚ Requests per       โ”‚ requests_per_second   โ”‚ โ€“        โ”‚ 30                  โ”‚ Per-source API rate โ”‚            โ”‚
      โ”‚ second             โ”‚                       โ”‚          โ”‚                     โ”‚ limit               โ”‚            โ”‚
      โ”‚ Workspace          โ”‚ workspace_concurrency โ”‚ โ€“        โ”‚ 8                   โ”‚ How many workspaces โ”‚            โ”‚
      โ”‚ concurrency        โ”‚                       โ”‚          โ”‚                     โ”‚ are enriched at     โ”‚            โ”‚
      โ”‚                    โ”‚                       โ”‚          โ”‚                     โ”‚ once                โ”‚            โ”‚
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

    Repair Keys (used in `liftoff audit --repair`) (4)
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ”‚ Label                โ”‚ Key                  โ”‚ Required โ”‚ Default โ”‚ Help                 โ”‚ When Unset           โ”‚
      โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
      โ”‚ Module workflow tool โ”‚ module_workflow_tool โ”‚ โ€“        โ”‚         โ”‚ What to write for    โ”‚ empty module         โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ modules exported     โ”‚ workflow tools stay  โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ with an empty        โ”‚ unrepaired until     โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ workflow tool:       โ”‚ this is set          โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ `TERRAFORM_FOSS`,    โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ `OPEN_TOFU`, or      โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ `CUSTOM`             โ”‚                      โ”‚
      โ”‚ Default branch       โ”‚ default_branch       โ”‚ โ€“        โ”‚ main    โ”‚ What to write for    โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ stacks and modules   โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ exported with no     โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ branch (they track   โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ their repo's         โ”‚                      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ default)             โ”‚                      โ”‚
      โ”‚ Custom runner image  โ”‚ custom_runner_image  โ”‚ โ€“        โ”‚         โ”‚ Untagged Docker      โ”‚ CUSTOM-workflow      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ image carrying the   โ”‚ stacks stay          โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ Terraform binaries   โ”‚ unrunnable and       โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ for CUSTOM-workflow  โ”‚ `liftoff audit`      โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ stacks; the repair   โ”‚ flags each until     โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ tags it with each    โ”‚ this is set          โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ stack's version      โ”‚                      โ”‚
      โ”‚ Worker pool          โ”‚ worker_pool_id       โ”‚ โ€“        โ”‚         โ”‚ Spacelift private    โ”‚ generated stacks run โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ worker pool id to    โ”‚ on the public pool   โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ assign to generated  โ”‚ when the account has โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ stacks; must match a โ”‚ one; `liftoff audit` โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ pool discover        โ”‚ flags each while     โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ recorded on the      โ”‚ private pools exist  โ”‚
      โ”‚                      โ”‚                      โ”‚          โ”‚         โ”‚ account              โ”‚                      โ”‚
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

    Mutations (4)
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ”‚ Name                โ”‚ Description                                โ”‚ When Unset                                  โ”‚
      โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
      โ”‚ secrets             โ”‚ capture sensitive variable values via a    โ”‚ sensitive variable values come over empty;  โ”‚
      โ”‚                     โ”‚ temporary agent โ€” mutates the source,      โ”‚ stage the workspaces and run `liftoff       โ”‚
      โ”‚                     โ”‚ always reverted                            โ”‚ mutate --allow-mutation secrets` to capture โ”‚
      โ”‚                     โ”‚                                            โ”‚ them, or set them in Spacelift after the    โ”‚
      โ”‚                     โ”‚                                            โ”‚ migration                                   โ”‚
      โ”‚ context-secrets     โ”‚ capture sensitive variable-set values via  โ”‚ sensitive variable-set values come over     โ”‚
      โ”‚                     โ”‚ a temporary agent โ€” creates and deletes    โ”‚ empty; run `liftoff mutate --allow-mutation โ”‚
      โ”‚                     โ”‚ one throwaway workspace per organization,  โ”‚ context-secrets` to capture them, or set    โ”‚
      โ”‚                     โ”‚ briefly attaches each variable set to it,  โ”‚ them on the migrated contexts in Spacelift  โ”‚
      โ”‚                     โ”‚ always reverted                            โ”‚ afterwards                                  โ”‚
      โ”‚ state               โ”‚ capture each staged workspace's Terraform  โ”‚ no Terraform state is captured, so `liftoff โ”‚
      โ”‚                     โ”‚ state โ€” reads the source, changes nothing  โ”‚ finalize state` has nothing to push and the โ”‚
      โ”‚                     โ”‚                                            โ”‚ migrated stacks start empty                 โ”‚
      โ”‚ module-git-versions โ”‚ resolve each published module version's    โ”‚ module versions keep no commit SHA, so      โ”‚
      โ”‚                     โ”‚ commit SHA from its VCS โ€” reads the        โ”‚ `liftoff finalize modules` skips them and   โ”‚
      โ”‚                     โ”‚ repository, changes nothing                โ”‚ the private registry migrates without its   โ”‚
      โ”‚                     โ”‚                                            โ”‚ published versions                          โ”‚
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Next
  $ liftoff configure --source <id> --set source.<key>=value

What to take from this screen:

  • The id in the title is what you pass to configure --source. Here that is terraform, which covers both Terraform Cloud and self-hosted Terraform Enterprise.
  • Config Keys is your settings checklist. Only the checked Required keys must be set (api_token here). Everything else has a Default you can live with, or a When Unset telling you what not setting it means. On Terraform Enterprise you will also need api_endpoint pointed at your install.
  • Repair Keys are not for discover. They feed liftoff audit --repair in step 7 and only come into play when an audit has findings to fix. You can set them now with everything else, and change them later by re-running configure.
  • Mutations preview a later, opt-in step. Discover never changes the source; the one step that does โ€” liftoff mutate โ€” is opt-in and per run, so you pass --allow-mutation <name> on every mutate that should use it, which is why mutations are not config keys. The two secret mutations are the ones worth planning for: without them, sensitive values come over empty and you re-enter them in Spacelift afterwards. secrets covers values set on a workspace. context-secrets covers values set on a variable set, and is separate because it makes a larger change โ€” it creates a throwaway workspace to read them through, then deletes it. Both revert the source when done. Nothing to do now โ€” the mutate step shows the flags.

The decision at this step is which source you are migrating from and, if it is self-hosted, what its API endpoint is. Note the id and move on.

Step 3 โ€” configure the sourceยป

1
liftoff configure --source terraform --set source.api_token='${TFC_TOKEN}'

Records the source and its settings in config.yaml. Values can reference environment variables: single-quote the reference so your shell doesn't expand it, and config.yaml stores the literal ${TFC_TOKEN} โ€” the value is resolved when a command runs, so the token itself never has to be written to disk. A reference that doesn't resolve is an error, not an empty string. Pasting the raw token works too; it just lives in config.yaml (and your shell history) instead.

--set repeats, and configure is incremental โ€” later runs merge into what's already saved:

1
liftoff configure --set source.api_endpoint=https://tfe.example.com --set source.workspace_concurrency=4

If a required setting is still missing, configure saves your progress and errors with exactly what's left โ€” the specimen in when something goes wrong is this very case. With the token set:

1
2
3
4
5
6
7
Source  terraform

Set Keys (1)
  - api_token

Next
  $ liftoff configure validate

Values are never echoed back, only key names.

Step 4 โ€” validate before running anythingยป

1
liftoff configure validate

The verdict on your configuration: what every key resolves to right now and what the run will mean, and โ€” once the config is complete โ€” it authenticates both key pairs and names who each resolved to. Read-only (it never writes), so run it as often as you like. This is the moment to catch a Spacelift endpoint pointed at the wrong account.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
Source  terraform

Auth
  User  apollorion
  Role  user

Spacelift Auth
  Endpoint  https://apps-tfc-migration-apollorion.app.spacelift.io
  Account   apps-tfc-migration-apollorion
  User      migration-key
  Role      admin

Config Keys (used in `liftoff discover`) (4)
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ Key                   โ”‚ Set โ”‚ Required โ”‚ Secret โ”‚ Effect                                                  โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ api_endpoint          โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ connects to https://app.terraform.io                    โ”‚
  โ”‚ api_token             โ”‚ โœ“   โ”‚ โœ“        โ”‚ โœ“      โ”‚ uses the configured value                               โ”‚
  โ”‚ requests_per_second   โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ the Terraform API is called at up to 30 requests/second โ”‚
  โ”‚ workspace_concurrency โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ up to 8 workspaces are enriched concurrently            โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Repair Keys (used in `liftoff audit --repair`) (4)
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ Key                  โ”‚ Set โ”‚ Required โ”‚ Secret โ”‚ Effect                                                        โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ module_workflow_tool โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ empty module workflow tools stay unrepaired until this is set โ”‚
  โ”‚ default_branch       โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ missing branches will default to: main                        โ”‚
  โ”‚ custom_runner_image  โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ CUSTOM-workflow stacks stay unrunnable and `liftoff audit`    โ”‚
  โ”‚                      โ”‚     โ”‚          โ”‚        โ”‚ flags each until this is set                                  โ”‚
  โ”‚ worker_pool_id       โ”‚ โ€“   โ”‚ โ€“        โ”‚ โ€“      โ”‚ generated stacks run on the public pool when the account has  โ”‚
  โ”‚                      โ”‚     โ”‚          โ”‚        โ”‚ one; `liftoff audit` flags each while private pools exist     โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Mutations (4)
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ Name                โ”‚ Effect                                       โ”‚ Enable With                                   โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ secrets             โ”‚ sensitive variable values come over empty;   โ”‚ liftoff mutate --allow-mutation secrets       โ”‚
  โ”‚                     โ”‚ stage the workspaces and run `liftoff mutate โ”‚                                               โ”‚
  โ”‚                     โ”‚ --allow-mutation secrets` to capture them,   โ”‚                                               โ”‚
  โ”‚                     โ”‚ or set them in Spacelift after the migration โ”‚                                               โ”‚
  โ”‚ context-secrets     โ”‚ sensitive variable-set values come over      โ”‚ liftoff mutate --allow-mutation context-      โ”‚
  โ”‚                     โ”‚ empty; run `liftoff mutate --allow-mutation  โ”‚ secrets                                       โ”‚
  โ”‚                     โ”‚ context-secrets` to capture them, or set     โ”‚                                               โ”‚
  โ”‚                     โ”‚ them on the migrated contexts in Spacelift   โ”‚                                               โ”‚
  โ”‚                     โ”‚ afterwards                                   โ”‚                                               โ”‚
  โ”‚ state               โ”‚ no Terraform state is captured, so `liftoff  โ”‚ liftoff mutate --allow-mutation state         โ”‚
  โ”‚                     โ”‚ finalize state` has nothing to push and the  โ”‚                                               โ”‚
  โ”‚                     โ”‚ migrated stacks start empty                  โ”‚                                               โ”‚
  โ”‚ module-git-versions โ”‚ module versions keep no commit SHA, so       โ”‚ liftoff mutate --allow-mutation module-git-   โ”‚
  โ”‚                     โ”‚ `liftoff finalize modules` skips them and    โ”‚ versions                                      โ”‚
  โ”‚                     โ”‚ the private registry migrates without its    โ”‚                                               โ”‚
  โ”‚                     โ”‚ published versions                           โ”‚                                               โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Next
  $ liftoff discover

Where liftoff sources showed templates, this shows results: every Effect is rendered with the value the run will actually use. Two more sections appear only when something needs attention: Missing Required (required keys with no value) and Unknown Keys (settings in config.yaml the source doesn't recognize โ€” usually a typo'd --set).

Read the Effect column top to bottom and check it against your intent. Worth deciding now:

  • Rate and concurrency (requests_per_second, workspace_concurrency) โ€” the defaults are safe for Terraform Cloud; a self-hosted TFE may want them lowered.
  • The repair keys (module_workflow_tool, default_branch, custom_runner_image, worker_pool_id) โ€” what liftoff audit --repair writes in step 6. They change nothing until an audit has findings to fix, and you can re-run configure to adjust them whenever. custom_runner_image matters if any workspace runs a Terraform version Spacelift's runner doesn't include: those stacks migrate onto the CUSTOM workflow tool and run the tool from that image, tagged with each stack's version (generate). The image has to carry the tool. Spacelift downloads nothing for a CUSTOM stack โ€” it runs the commands in the mounted workflow.yml, so a missing binary surfaces as sh: terraform: not found and the run stops in INITIALIZING. Spacelift's own runner-terraform image does not satisfy this; build on it and install the versions you need. Give it no tag. An untagged image is what lets the repair tag each stack with the version its workspace ran, so every stack keeps its own. If you tag it yourself โ€” โ€ฆ/runner-terraform:latest, say โ€” that exact image is written to every CUSTOM stack, so whatever tool version it carries is the one they all run. liftoff audit prints the reference it would write per stack, so you can check before repairing. worker_pool_id matters when your Spacelift account has private worker pools: generated stacks omit the attribute (and land on the public pool โ€” an audit error instead when the account has none) unless you set this to a pool id discover recorded, then audit --repair writes it.
  • The mutations โ€” opt-ins for the later mutate step, secrets here being one example. A mutation is never remembered: you pass --allow-mutation <name> on every mutate that should use it, which is why they aren't config keys. Decide whether sensitive variable values should be captured (the mutate step shows the flag) or re-entered in Spacelift after the migration.

Building the runner image for CUSTOM stacksยป

If any workspace runs a Terraform newer than Spacelift bundles, its stack migrates onto the CUSTOM workflow tool and runs the binary your image carries. Spacelift downloads nothing for it โ€” you hold the licence for those versions, so you build the image.

You need one tag per version in the batch. liftoff audit names the version in each finding, so the audit output is the list:

1
2
3
custom-workflow-missing-runner-image (error) (2)
  stack ws-abc123 runs version 1.9.0 on the CUSTOM workflow tool and has no runner image
  stack ws-def456 runs version 1.10.3 on the CUSTOM workflow tool and has no runner image

A Dockerfile that adds one version on top of Spacelift's runner image. Build on that image rather than a bare Alpine or hashicorp/terraform: the worker calls ps to watch the container, and the image has to carry the spacelift user (UID 1983) that jobs run as. The fetch stage keeps the build from depending on which archive tools the base image ships:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ARG TERRAFORM_VERSION

FROM alpine:3 AS fetch
ARG TERRAFORM_VERSION
RUN apk add --no-cache curl unzip \
 && curl -sSLo /tmp/tf.zip \
      "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \
 && unzip /tmp/tf.zip -d /out

FROM public.ecr.aws/spacelift/runner-terraform:latest
USER root
COPY --from=fetch /out/terraform /usr/local/bin/terraform
RUN chmod 0755 /usr/local/bin/terraform
USER spacelift

Build and push one tag per version, tagging each with the version itself โ€” that is what the repair writes:

1
2
3
4
for v in 1.9.0 1.10.3; do
  docker build --build-arg "TERRAFORM_VERSION=$v" -t "$REGISTRY/liftoff-runner:$v" .
  docker push "$REGISTRY/liftoff-runner:$v"
done

Where you push matters. On Spacelift's public worker pool the image must be public, and only these registries are accepted: public.ecr.aws, dkr.ecr.<region>.amazonaws.com, docker.io, registry.hub.docker.com, ghcr.io, gcr.io, docker.pkg.dev, azurecr.io, quay.io, registry.gitlab.com. A private image requires a private worker pool: the public pool caches images across accounts, so it only ever pulls public ones. liftoff audit checks this rather than leaving you to find out from a run that never starts: a stack heading for the public pool with an image from anywhere else is an error, runner-image-not-pullable (audit).

Then point the setting at the untagged name and repair:

1
2
liftoff configure --set source.custom_runner_image=$REGISTRY/liftoff-runner
liftoff audit --repair

Each CUSTOM stack gets $REGISTRY/liftoff-runner:<its own version>. Build for linux/amd64 โ€” that is what Spacelift workers run. A missing or non-executable binary shows up as sh: terraform: not found, with the run stopping in INITIALIZING.

When the effects read the way you intend and the token authenticated, you are ready for discover.