The .turnkey Directory
Turnkey uses a .turnkey directory in your project root to store build artifacts, caches, and generated cells. This convention provides automatic isolation from language toolchains.
Why .turnkey?
The .turnkey directory serves as the isolation directory for Buck2 builds. By using a dot-prefixed name, we get automatic exclusion from most language toolchains:
| Tool | Behavior | Configuration Needed |
|---|---|---|
| Go | Ignores directories starting with . or _ | None (built-in) |
| Cargo | Doesn't auto-discover crates in dot directories | None (built-in) |
| pytest | Automatically ignores dot directories | None (built-in) |
| Jest | Requires explicit configuration | Yes |
| Vitest | Requires explicit configuration | Yes |
This means Go won't try to compile generated Buck2 cells, Cargo won't discover them as workspace members, and pytest won't scan them for tests.
Directory Structure
.turnkey/
├── books/ # mdbook serve output (gitignored)
├── prelude/ # Symlink to Buck2 prelude derivation
├── toolchains/ # Symlink to generated toolchains cell
├── godeps/ # Real directory: the write-once Go cell (tk materialize)
│ ├── .buckconfig
│ ├── .deps-file-sha256 # the go-deps.toml it was built from
│ ├── _store/<store path name> # one symlink per module, never retargeted
│ └── vendor/<import path>/rules.star # an alias package per Go package
├── godeps.lock # held while tk materialize runs
├── jsdeps/ # Real directory: the write-once JavaScript cell (tk materialize)
│ ├── .buckconfig
│ ├── .deps-file-sha256 # the js-deps.toml it was built from
│ ├── rules.star # an instance per pnpm snapshot, an alias per direct dependency
│ ├── _store/<store path name> # one symlink per package, never retargeted
│ └── vendor/<name>@<version>/rules.star # an alias package per package
├── jsdeps.lock # held while tk materialize runs
├── pydeps/ # Real directory: the write-once Python cell (tk materialize)
│ ├── .buckconfig
│ ├── .deps-file-sha256 # the python-deps.toml it was built from
│ ├── _store/<store path name> # one symlink per distribution, never retargeted
│ └── vendor/<name>/rules.star # an alias package per distribution
├── pydeps.lock # held while tk materialize runs
├── rustdeps/ # Real directory: the write-once Rust cell (tk materialize)
│ ├── .buckconfig
│ ├── .deps-file-sha256 # the rust-deps.toml it was built from
│ ├── _store/<store path name> # one symlink per crate, never retargeted
│ └── vendor/<crate>@<version>/rules.star, vendor/<crate>/rules.star # aliases
├── rustdeps.lock # held while tk materialize runs
├── soldeps/ # Real directory: the write-once Solidity cell (tk materialize)
│ ├── .buckconfig
│ ├── .deps-file-sha256 # the solidity-deps.toml it was built from
│ ├── rules.star # bundle, and an alias per package
│ ├── _store/<store path name> # one symlink per package, never retargeted
│ └── vendor/<name>/rules.star # an alias package per package, beside
│ # links to its files for native forge
├── soldeps.lock # held while tk materialize runs
├── gcroots/godeps, gcroots/jsdeps, gcroots/pydeps, gcroots/rustdeps, gcroots/soldeps # GC roots for the cells' current indexes
├── .cell-targets # the store symlinks' targets, for tk's cell-freshness check
├── .cell-targets.<isolation dir> # the same, for another isolation directory's daemon
├── edits/, patches/ # tk compose's edits and generated patches
└── sync.toml # Symlink to the rules tk sync follows
The prelude and toolchains cells are symlinks to Nix store paths containing
the generated Buck2 cells. The Go, Rust, Python, Solidity and JavaScript
cells are real directories that tk materialize, run by the shell, keeps in
line with the cell index Nix builds: a dependency change rewrites only the
entries for the modules, crates, distributions or packages that changed
(ADR 0004,
ADR 0008,
ADR 0010,
ADR 0011,
ADR 0012).
Don't edit it; the shell rewrites it on every load.
Symlinked Cells and Plain buck2
Two cells are symlinks into the Nix store, repointed when the shell builds a new one:
.turnkey/prelude, the prelude;.turnkey/toolchains, the toolchains cell.
.buckconfig and .turnkey/sync.toml are store symlinks too. They stay
symlinks: they change only when the shell is rebuilt (a turnkey upgrade, a
nix flake update, a toolchain.toml or flake.nix edit), which reloads
it.
The deps cells, rustdeps, godeps, pydeps, jsdeps and soldeps, are
the write-once directories above, which moved off symlinks in
#234: a
dependency change never repoints a link, so any caller, plain buck2
included, reads the new version. A deps cell you set yourself with
buck2.<language>.cell, a derivation without a cell index, is still a
symlink.
A running daemon doesn't notice a repointed symlink. It keeps the build files and sources it already read through the old target, and reads the packages it hadn't loaded from the new one. A build can then mix the old and the new cell, with no error.
tk checks for it. Before each command it syncs for (every buck2
command but the pass-through ones),
tk reads the targets of .buckconfig and of every entry of .turnkey/
that is a symlink into the store, and compares them with those it saved in
.turnkey/.cell-targets. When one was added, removed or repointed, it
prints tk: cell symlink changed, restarting buck2 daemon (unless
--quiet), runs buck2 kill, and saves the new targets. The first run only saves them. The new
daemon starts without the old one's state, so the next build re-runs its
actions.
Each isolation directory has a daemon of its own, so with
--isolation-dir, tk checks against that directory's state file instead,
.turnkey/.cell-targets.<dir> (.cell-targets.turnkey-ci for
tk --isolation-dir=ci), and restarts that directory's daemon:
buck2 --isolation-dir .turnkey-ci kill. A change one daemon was restarted
for still restarts each other one the next time tk runs against it.
The check doesn't cover:
- plain
buck2: a script, a tool that runsbuck2itself, or a shell withTURNKEY_NO_ALIAS=1. The shell'sbuck2alias fortkonly applies to the interactive shell; tk --no-sync, which skips it with the sync;- an isolation directory set only through
BUCK_ISOLATION_DIR: without--isolation-dir,tkrestarts the daemon buck2 picks from the environment, but checks it against.turnkey/.cell-targets, the state of the shell's own.turnkeydaemon. Pass--isolation-dirinstead.
So after the shell is rebuilt, before a plain buck2 call, either:
- run a
tkcommand that syncs, such astk build, which restarts the daemon if a symlink changed; or - run
buck2 kill, with the same--isolation-diras the call.
In turnkey's own repository, these run plain buck2:
- the e2e tests (
e2e/tests/), which callbuck2 build,testandrunin a fixture project's shell; scripts/ci-smoke.sh, whose integration level builds and tests each language's example;check-test-caching(src/cmd/check-test-caching/__main__.py), which runsbuck2 bxl;- the
starlark-lintgit hook,buck2 --isolation-dir .turnkey-lint starlark lint. It has a daemon of its own, whichtknever restarts: after a turnkey upgrade, runbuck2 --isolation-dir .turnkey-lint kill.
Buck2 Configuration
The .buckconfig sets the isolation directory:
[buck2]
isolation_dir = .turnkey
This tells Buck2 to store all build outputs under .turnkey/buck-out/ instead of the default buck-out/.
The tk Command
The tk command wraps buck2 and automatically translates the --isolation-dir flag to use .turnkey-prefixed directories:
# These are equivalent:
tk --isolation-dir=foo build //...
buck2 --isolation-dir=.turnkey-foo build //...
This allows multiple isolated builds while maintaining the dot-prefix convention.
JavaScript/TypeScript Configuration
Unlike Go, Cargo, and pytest, JavaScript test runners need explicit configuration to ignore dot directories.
Jest
Add to your jest.config.js:
module.exports = {
testPathIgnorePatterns: [
'/node_modules/',
'/buck-out/',
'/\\.' // Ignore all dot-prefixed directories
],
};
Or in package.json:
{
"jest": {
"testPathIgnorePatterns": [
"/node_modules/",
"/buck-out/",
"/\\."
]
}
}
Vitest
Add to your vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: [
'**/node_modules/**',
'**/buck-out/**',
'**/.*/**' // Ignore all dot-prefixed directories
],
},
});
Migration Notes
If you're migrating from a project that used buck-out/ directly:
-
One-time cache invalidation: Buck2 caches are stored per isolation directory. Switching to
.turnkeymeans a clean rebuild on first run. -
Update .gitignore: Ensure
.turnkey/is in your.gitignore:.turnkey/ -
Update CI scripts: If CI scripts reference
buck-out/, update them to.turnkey/buck-out/.
Multiple Isolation Directories
For advanced use cases (parallel builds, different configurations), you can use multiple isolation directories:
# Development build
tk build //...
# Release build with different isolation
tk --isolation-dir=release build //...
# Creates .turnkey-release/
# CI build
tk --isolation-dir=ci build //...
# Creates .turnkey-ci/
Each isolation directory maintains its own:
- Buck2 daemon
- Build cache
- Output artifacts
This is useful for:
- Running multiple Buck2 daemons simultaneously
- Keeping CI caches separate from local development
- Testing different build configurations