Skip to content
Latchkey LogoLatchkey home

Docker Compose build must be a string, in CI

Docker Compose build must be a string is the schema validator naming one branch of a rule with two, and following it literally will send you to rewrite a mapping that was never the problem. Compose accepts a path string or a mapping under build, and the message picks the first branch it checked rather than listing both.

Diagram of a two branch schema rule and the validator reporting only the first branch
The schema allows a string or a mapping. Both branches fail, both are equally specific, and the reporter keeps the first, so the message reads "services.api.build must be a string".

What this error means

A docker compose build, docker compose config or up --build stops during validation with a path, a colon and a sentence naming the key and one type. Nothing is built and no image is touched. The sentence says string even when a mapping would have been fine, which is why it reads as though Compose has forgotten half its own schema. The block below is four Compose files run on this machine.

Captured locally on Docker Compose v5.3.1, 2026-09-21
--- build written as a list
validating /tmp/compose-list.yml: services.api.build must be a string
--- build set to a number
validating /tmp/compose-num.yml: services.api.build must be a string
--- build left empty
validating /tmp/compose-empty.yml: services.api.build must be a string
--- a misspelled key inside a valid mapping, which is a different failure
validating /tmp/compose-badkey.yml: services.api.build additional properties 'contextt' not allowed

The message names one branch of a two branch rule

In the Compose specification schema, build is a choice: either a string holding a context path, or a mapping with keys such as context and dockerfile. When your value is neither, the JSON schema library reports a failure for each branch, and the Compose loader then has to turn a tree of failures into one sentence.

It does that by walking the tree for the most specific cause, scoring each by the depth of the path it points at. Both branch failures point at exactly the same path, so they score the same, and the comparison keeps the first one it saw because it only replaces the winner on a strictly higher score. The first branch in the schema is the string, so the string is what you are told about. The mapping branch failed too, and nothing says so.

What you wrote under build:What Compose v5.3.1 printed
A list, - context: ./apiservices.api.build must be a string
A number, 42services.api.build must be a string
Nothing at allservices.api.build must be a string
A mapping with a misspelled keyservices.api.build additional properties 'contextt' not allowed

Common causes

A stray dash turned the mapping into a list

The most common shape by a distance. One dash in front of the first child makes the whole value a list, and a list is not one of the two accepted types. Everything else about the file is correct, which is why the error reads as though Compose is confused rather than as though the file is.

The key is present with no value

A build: line whose children were all commented out, or a template that emits the key unconditionally, gives Compose an empty value. An empty value is neither a string nor a mapping and gets the same sentence. It is worth checking early because it is invisible when skimming: the line looks fine and the absence is below it.

The value is a scalar of the wrong kind

A number, a boolean, or a quoted value that YAML resolved to something other than a string. This one is rare by hand and common from generators, where a templating pass writes a bare value that YAML then types for you. The fix is quoting rather than restructuring.

Indentation attached the children to the wrong key

Under indented children can land on the service rather than on build, leaving build empty and adding unexpected keys to the service. You may get the build message, an additional properties message, or both across two runs as you fix one and reveal the other. Rendering the merged config is the quickest way to see what Compose actually parsed.

How to fix it

Read the message as "not one of the accepted types"

  1. Do not convert your mapping to a string. The mapping form is valid and the sentence is naming only the first branch of the rule.
  2. Look at the YAML type of the value: a list, an empty value, a number or a boolean are all ways to get here.
  3. Fix the type, not the shape you intended.
Terminal
docker compose -f compose.yaml config

Validate in CI before anything builds

Rendering the merged configuration is fast, needs no daemon work and fails on exactly these problems. Put it in its own step so the failure is reported as a configuration problem rather than as a build problem, which is what it is.

.github/workflows/ci.yml
- name: Validate compose files
  run: |
    for f in compose.yaml compose.ci.yaml; do
      docker compose -f "$f" config >/dev/null
    done

Distinguish the two messages, because they mean opposite things

A type message means the value under build is the wrong kind of thing. An additional properties message means the value is the right kind of thing and one key inside it is not recognized. The second names the offending key, so it is the easier of the two to act on and you should not confuse it with the first.

Terminal
# wrong type, the value is a list
services.api.build must be a string

# right type, wrong key inside it
services.api.build additional properties 'contextt' not allowed

Give the editor a schema so YAML shape errors never reach CI

Most of these are caught by an editor that knows the Compose schema, because a list where a mapping belongs is flagged as you type. This is the version of the fix that stops the next one as well as this one.

.vscode/settings.json
# .vscode/settings.json
{
  "yaml.schemas": {
    "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json": [
      "compose*.y*ml",
      "docker-compose*.y*ml"
    ]
  }
}

Compose does not use the word object, and has not for years

If you are searching for the phrase "must be a string or object" you will not find it in a Compose log, because the loader translates schema type names before printing them. An object becomes a mapping and an array becomes a list, on the way to the sentence. That translation has been in the same function across compose-go v1.20.2, v2.4.7 and the current main branch, so it is not a recent change you missed.

This matters for searching. The YAML words are what Compose prints, so search for "must be a mapping" and "must be a list" when you are looking for other people with the same problem, and for the JSON schema words only when you are reading the schema itself.

compose.yaml
# both of these are valid: a path string, or a mapping
services:
  api:
    build: ./api
  web:
    build:
      context: ./web
      dockerfile: Dockerfile

A stray dash is the usual way to arrive here

YAML turns a key whose children begin with a dash into a list, and a list is neither of the two things build accepts. The edit that causes it is almost always mechanical: pasting a block from another key that genuinely is a list, or a reformatting pass that added a dash to the first child and nothing else.

The second common arrival is an empty value, from a key that was commented out below without the key itself being removed. Compose reads that as an empty value and refuses it with the same sentence, which is confusing because nothing about the file looks like a type mistake.

compose.yaml
# a list, which is why you got "must be a string"
services:
  api:
    build:
      - context: ./api

# the same file with the dash removed
services:
  api:
    build:
      context: ./api

Why no recorded run backs this page

This failure never reaches a runner in any meaningful sense: it is decided by a schema validation pass over one YAML file, before Compose contacts a daemon or considers a build. The whole of it fits in four two line files, which is why this page shows four of them rather than one recorded job.

There is also a specific reason not to record it. The message depends on the Compose version, because it depends on which JSON schema library the loader uses and how it picks a cause. A recorded run would pin this page to whatever Compose the runner image had that week. Naming the version beside each captured line does the same job and keeps ageing visible.

How to prevent it

  • Run docker compose config on every compose file in a step of its own, before anything builds.
  • Point your editor at the Compose schema so type mistakes are flagged while you type.
  • Quote values that a generator writes into compose files so YAML cannot retype them.
  • Remove a key entirely rather than leaving it with its children commented out.

Frequently asked questions

Why does Compose say build must be a string when a mapping works?
Because the rule has two branches and the reporter keeps only one. Both the string branch and the mapping branch fail on the same path, they score equally specific, and the comparison replaces the winner only on a strictly higher score, so the first branch survives. The mapping is still valid; you were just not told about it.
Does Compose ever say object instead of mapping?
No. The loader translates schema type names before printing them, so object becomes mapping and array becomes list. That translation has been in the same function for several major versions, so a search for a phrase containing the word object will not find real Compose output.
What is the difference between a type message and an additional properties message?
A type message means the value under the key is the wrong kind of thing entirely. An additional properties message means the value is the right kind and one key inside it is not in the schema, and it names that key. They are produced by different rules and need different fixes.
Will docker compose config catch this before my build step?
Yes, and it is the cheapest gate you can put in a workflow. It renders the merged configuration and returns a non zero exit on any validation failure without contacting a daemon or building anything. Running it across every compose file takes about as long as starting the step.

Related guides

References

Latchkey runs Compose builds at $0.0025/min at 2 vCPU with a layer cache that survives between jobs. Start free → 30-day trial · No credit card