# How to generate an SDK with Fern from generators.yml

> Set up a Fern project from an OpenAPI document, read the generators.yml it writes, validate it offline, and know which generator group runs before anything runs.

Source: https://voxgig.com/howto/generate-an-sdk-with-fern

- Audience: api-producer
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Run fern init --openapi openapi.json --organization YOUR_ORG to create fern/ with fern.config.json and generators.yml, then fern check --local to validate the definition. fern generate runs the default-group on Fern's cloud and asks you to log in; fern generate --local runs the same generators as Docker images on your machine. Name the group every time, because the group decides which SDKs are built and where they go.

---
## You will need

Node 22 or later, an OpenAPI 3 document, and, for local generation,
[Docker](https://docs.docker.com/engine/install/). Verified 2026-10-21 against Node 22.22.0,
`fern-api` 5.163.1, and `yaml` 2.9.1. The CLI is installed from a lockfile as `fern-api`, so
`npx fern` runs the pinned release. That release writes `generators.yml`, which Fern's
documentation files under its legacy SDK docs. A later release writes an
[`sdk-config.yml`](https://buildwithfern.com/learn/sdks/reference/sdk-config) from `fern init`
instead, with targets in place of groups. Generation itself was not run here: `fern generate`
needs a Fern account and `fern generate --local` needs a Docker daemon, which the verification
environment did not have. The commands on this page stop where either would start.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Fern](https://buildwithfern.com/learn/sdks/legacy/reference/generators-yml) | You want maintained generators and one `generators.yml` deciding languages, versions, and outputs | A login for cloud generation or Docker for local, and a pinned generator version per language that you upgrade by hand | You cannot run Docker and will not create an account, or you need a language Fern does not ship |
| [OpenAPI Generator](https://openapi-generator.tech/docs/usage/) | You want the widest language list with no account, no Docker, and templates you can edit | A Java 11 runtime or its Docker image, endpoint-shaped output, and per-language templates to own once you customize | You want entity-shaped SDKs that someone else maintains |
| [Speakeasy](https://www.speakeasy.com/docs/speakeasy-reference/workflow-file) | You want a `workflow.yaml` in your repository that names sources and targets and pins the CLI | A CLI that attempts an upgrade to its latest release on every run unless `speakeasyVersion` is pinned, and a vendor workflow around your repository | You want to run the generator with no vendor service in the loop |

The three differ in where generation runs. Fern's generators are Docker images, run on Fern's
cloud when you log in or by your own Docker daemon with `--local`. OpenAPI Generator is a jar, so
a Java runtime and a command are the whole path, with no account anywhere on it. Speakeasy keeps
a `workflow.yaml` in your repository that names sources and targets and pins its CLI version, and
that pin defaults to `latest`, which upgrades the CLI underneath you.

## Initialize the project from the document

`fern init` takes the document and an organization name and writes a `fern/` directory beside
it. It needs no login.

```bash
npx fern init --openapi openapi.json --organization acme
```

The command printed `Created new API: ./fern` and wrote three files: a `.gitignore`, the project
config, and the generator config.

```json title="fern/fern.config.json"
{
    "organization": "acme",
    "version": "5.163.1"
}
```

```yaml title="fern/generators.yml"
api:
  specs:
    - openapi: ../openapi.json
default-group: local
groups:
  local:
    generators:
      - name: fern-typescript-sdk
        output:
          location: local-file-system
          path: ../sdks/typescript
        version: 3.107.3
  cloud:
    generators:
      - name: fern-python-sdk
        version: 5.41.3
        output:
          location: local-file-system
          path: ../sdks/python
```

`fern.config.json` pins the CLI release the project runs, which `fern init` sets to the release
current on the day you run it.[^1] `generators.yml` names the spec, a default group, and one
generator: the TypeScript SDK, at a pinned version, written to a directory beside `fern/`. The
`cloud` group came from a second command, `npx fern add fern-python-sdk --group cloud`, which
looked up that generator's current version and appended the group. Both versions are yours to
upgrade from then on, since the CLI changes them only when asked.

`inspect.mjs` reads the file the CLI reads and prints what each form of the generate command
would run.

```bash
node inspect.mjs
```

```text output
specs: ../openapi.json
default group: local
group local  (fern generate)
  fern-typescript-sdk 3.107.3  ->  local-file-system ../sdks/typescript
group cloud  (fern generate --group cloud)
  fern-python-sdk 5.41.3  ->  local-file-system ../sdks/python
```

## Validate offline and read what Fern made of the document

`fern check` [validates](https://buildwithfern.com/learn/cli-api-reference/cli-reference/general-commands)
the definition and the configuration. The `--local` flag keeps the work on your machine; without
it, the CLI's own help text says, the definition is sent to Fern's API for validation.

```bash
npx fern check --local
```

```text output
All checks passed
```

Fern reads OpenAPI into a definition format of its own, and `fern write-definition` writes that
reading under `fern/.definition`, which the `.gitignore` from `fern init` excludes.
`definition.mjs` runs it and prints the types and endpoints it found.

```bash
node definition.mjs
```

```text output
[api]: Wrote definition to fern/.definition
types: Customer, Order, OrderStatus
  createOrder    POST   /orders                  -> Order
  deleteOrder    DELETE /orders/{orderId}        -> no body
  getCustomer    GET    /customers/{customerId}  -> Customer
  getOrder       GET    /orders/{orderId}        -> Order
  listCustomers  GET    /customers               -> list<Customer>
  listOrders     GET    /orders                  -> list<Order>
  updateOrder    PATCH  /orders/{orderId}        -> Order
```

Seven endpoints and three types, with the inline enum promoted to `OrderStatus`. Read this before
generating, because an operation missing here is missing from every SDK.

## Choose the group, then generate

A bare `fern generate` runs the default group remotely. The
[SDK commands](https://buildwithfern.com/learn/cli-api-reference/cli-reference/sdk-commands) page
says remote generation, the default, requires authentication, and the CLI says so as well.

```bash
npx fern generate
```

```text output
[api]: Using default group 'local' from generators.yml
Authentication required. Please run 'fern login' or set the FERN_TOKEN environment variable.
```

Both lines matter. The first names the group that would have run, which is the one thing to check
before a login makes the command real. `--local` takes the account out of the loop and puts Docker
in: the same page says a Docker daemon must be running on your machine, because SDK generation
runs inside a Docker container.[^2]

```bash title="generate-local.sh"
npx fern generate --local
npx fern generate --group cloud --local
```

Run here, the first command validated the definition, named the image it was about to execute,
`fernapi/fern-typescript-sdk` at the pinned version, and stopped when no Docker daemon answered.
Each group's `output.path` says where its SDK lands once a daemon is present: `sdks/typescript`
for the first command and `sdks/python` for the second, which leaves the first alone.

## Check it worked

Five tests with the [Node test runner](https://nodejs.org/api/test.html) pin the config and run
the CLI twice: once to validate, once to refuse a group that does not exist.

```js title="fern.test.mjs"
test('a group that does not exist is refused before anything runs', () => {
  const r = spawnSync('npx', ['fern', 'generate', '--group', 'nope'], { encoding: 'utf8' })
  assert.equal(r.status, 1)
  assert.match(r.stdout + r.stderr, /'nope' is not a valid group or alias\. Available groups: local, cloud/)
})
```

```bash
node --test fern.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

`# pass 5` is the line to look for. The test for the missing group asserts exit status 1 and the
message naming the available groups, so a typo in a CI script fails before any image is pulled.

## When it goes wrong

Every generator in a group runs when the group runs. A default group holding a TypeScript, a
Python, and a Go generator that publish to three registries runs all three on a bare
`fern generate`, which is what forgetting `--group` costs. Keep `default-group` pointing at a
group that writes to the local file system, as `fern init` does, and name the publishing group
explicitly. Without a default group the CLI refuses and lists the choices, which the copy of the
project under `nodefault/` shows.

```bash
cd nodefault && npx fern generate
```

```text output
[api]: No group specified. Use the --group option, or set "default-group" in generators.yml:
        › local  fern generate --group local
        › cloud  fern generate --group cloud
```

A group name is misspelled. The CLI stops before anything runs and lists what exists.

```bash
npx fern generate --group nope
```

```text output
[api]: 'nope' is not a valid group or alias. Available groups: local, cloud
```

The CLI that runs is not the CLI you installed. `fern.config.json` pins a release, and the
version policy says each project runs the release it pins. A project initialized with an earlier
installed release can therefore carry a later pin from the day it was created. `fern upgrade` moves
the pin on purpose. Read the file before assuming the two match.

## When not to do this

Do not pick Fern if you cannot run Docker and will not hold a vendor account. Both routes to a
generated SDK go through one or the other, and OpenAPI Generator produces a client from a jar
with neither.[^3]

Do not let `default-group` name a group that publishes. A bare `fern generate` runs whatever that
group holds, so a maintainer who runs it to try a change ships to a registry. Default to a group
that writes to the local file system and make publishing a group you have to name.

Do not take `fern check --local` passing as a generated SDK. It validates the definition and the
configuration, and `fern generate --local` ran the same checks here before it reached for Docker.
The SDK exists when the image has run, not before.

Do not loosen the generator versions. `generators.yml` takes an exact version per generator and
`fern init` writes one. Keep it exact, because a version that moves turns the next regeneration
into a review of someone else's changes.

## Related how-tos

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Tell an entity-shaped SDK from an endpoint-shaped one](/howto/measure-entity-versus-endpoint-shaped-output)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `fern-api` 5.163.1, and `yaml` 2.9.1. Every output block is
what the command preceding it printed, run in the page's code directory after `npm ci`.
`fern init` and `fern add` ran once to write the committed `fern/` directory and are not re-run
by the checks, because both fetch current versions from the registry. `fern generate --local` was
run once to observe the Docker requirement and did not produce an SDK. Nothing on this page ran
with a Fern account, and the generator versions in `generators.yml` are the ones the CLI wrote on
2026-10-18. On 2026-10-21, `fern init` from `fern-api` 5.170.3, run once in a scratch directory,
wrote `sdk-config.yml` and no `generators.yml`.

[^1]: Fern's [version policy](https://buildwithfern.com/learn/cli-api-reference/cli-reference/version-policy)
says each project runs the version it pins in `fern.config.json`, and that an organization policy
can move the version that runs without changing the file. `fern init` writes the release current
on the day it runs rather than the one running it. A project initialized here with the previous
CLI release carried the newer number before that release was installed anywhere on the machine.

[^2]: The [SDK commands](https://buildwithfern.com/learn/cli-api-reference/cli-reference/sdk-commands)
page says a Docker daemon must be running on your machine, as SDK generation runs inside a Docker
container. The container is the generator. `generators.yml` names it by package name and version,
and the CLI resolves that pair to an image under the `fernapi` namespace. That image name is what
it printed here in the moment before it found no daemon to talk to.

[^3]: The OpenAPI Generator [installation page](https://openapi-generator.tech/docs/installation/)
asks for a Java 11 runtime at a minimum and describes its npm package as a cross-platform wrapper
around the jar. It also offers a Docker image that acts as a standalone executable. The page asks for
no account at any step.