Configuration reference
Everything pullpress.config.yml understands — the one file that turns your repo into a CMS.
The config file lives in the root of your repository and is committed like everything else, so changes to your content model go through review too. It has a handful of top-level keys, a list of collections, and a small set of field types.
Top-level keys
| Key | Description |
|---|---|
version | Config schema version. Currently always 1. |
media.folder | Repository path where uploaded images are committed, e.g. public/uploads. |
media.public_path | URL prefix the published site serves that folder from, e.g. /uploads. Used to write correct image links into your Markdown. |
collections | The list of content collections editors can work with. Each collection has a name, a label, a type and fields. |
components | Optional. A map of reusable named field groups, referenced from fields with type: component. Define a group once, reuse it across collections. |
Collection types
A collection describes one kind of content. Its type decides how entries map to files in the repository.
folder — one file per entry
Every entry is its own Markdown file inside the configured folder. Editors can create, edit and delete entries — ideal for blog posts, news items or team members.
collections:
- name: blog
label: Blog posts
type: folder
folder: content/blog
fields:
- { name: title, label: Title, type: string, required: true }
- { name: date, label: Date, type: date, default: now }
- { name: body, label: Body, type: markdown }Sites that use one folder per entry (Astro content collections, Hugo leaf bundles: content/my-post/index.md) set structure: bundle. Add media: colocatedto store an entry's images inside its own folder, referenced relatively (./photo.jpg) so your generator's image pipeline picks them up:
collections:
- name: projects
label: Projects
type: folder
folder: redesign/content
structure: bundle # one folder per entry: content/my-post/index.md
media: colocated # images live inside the entry folder (./photo.jpg)
slug: "{{date}}-{{slug:title}}"
fields:
- { name: title, label: Title, type: string, required: true }
- { name: date, label: Date, type: date, default: now }
- { name: body, label: Body, type: markdown }files — fixed pages
A fixed set of files you list explicitly. Editors can edit them but not add or remove any — right for one-off pages like About or Contact.
collections:
- name: pages
label: Pages
type: files
files:
- { name: about, label: About page, file: content/about.md }
- { name: contact, label: Contact page, file: content/contact.md }
fields:
- { name: title, label: Title, type: string, required: true }
- { name: body, label: Body, type: markdown }data — a single structured data file
One JSON or YAML file edited entirely through a form, with no Markdown body. Use it for structured data your templates read directly: opening hours, navigation menus, site settings.
collections:
- name: opening_hours
label: Opening hours
type: data
file: data/opening-hours.yml
fields:
- name: hours
label: Weekly hours
type: list
of: object
fields:
- { name: day, label: Day, type: select, options: [mon, tue, wed, thu, fri, sat, sun] }
- { name: open, label: Opens, type: string }
- { name: close, label: Closes, type: string }
- { name: closed, label: Closed all day, type: boolean, default: false }Field types
Each field in a collection becomes a form control in the editor. The common options label, required and default work on every type. Two more help your editors: a help string shows a hint under the field, and a placeholdershows example text inside an empty input. Required fields get a red asterisk and a friendly “what's missing” summary on submit.
| Type | Description | Example |
|---|---|---|
string | A single line of text. | { name: title, type: string, required: true } |
text | Multi-line plain text, shown as a textarea. | { name: summary, type: text } |
markdown | Rich text in the clean editor, stored as Markdown. | { name: body, type: markdown } |
number | An integer or decimal number. | { name: price, type: number } |
boolean | A yes/no toggle. | { name: featured, type: boolean, default: false } |
date | A calendar date. default: now fills in today. | { name: date, type: date, default: now } |
datetime | A date with a time of day. | { name: published_at, type: datetime } |
select | One choice from a fixed list, via options. | { name: category, type: select, options: [news, recipes] } |
multiselect | Multiple choices from a fixed list, shown as checkboxes. | { name: categories, type: multiselect, options: [news, events] } |
url | A web address, validated as https://… | { name: website, type: url } |
email | An email address, validated. | { name: contact, type: email } |
color | A color picker, stored as #rrggbb. | { name: accent, type: color } |
image | An image upload, committed to the media folder. | { name: cover, type: image } |
list | A repeatable list of items; of sets the item type. | { name: tags, type: list, of: string } |
object | A group of nested fields, defined with fields. | { name: cta, type: object, fields: [...] } |
relation | A reference to an entry in another folder collection (stores its slug). | { name: author, type: relation, collection: team } |
blocks | Stackable sections of different shapes (each item carries a _type key). | { name: sections, type: blocks, blocks: [...] } |
hidden | Not shown to editors; written along with a fixed value (e.g. a layout key). | { name: layout, type: hidden, default: post } |
code | A monospace code block, with an optional language hint. | { name: snippet, type: code, language: js } |
map | A location, edited as latitude/longitude (stored as 'lat,lng'). | { name: location, type: map } |
compute | A read-only value derived from a template over sibling fields. | { name: ref, type: compute, template: '{{year}}-{{slug}}' } |
keyvalue | Arbitrary key/value pairs, e.g. extra metadata. | { name: meta, type: keyvalue } |
seo | SEO panel: title + description (with length hints), canonical URL, OG image and noindex, with a search-snippet and JSON-LD preview. | { name: seo, type: seo } |
component | Expands a reusable field group from the top-level components map. | { name: cta, type: component, component: callToAction } |
list and object compose: a list of object gives you repeatable groups of fields, like the weekly opening hours in the example above.
Conditional fields (showIf)
Any field can declare showIfto appear only when a sibling field matches. Hidden fields are cleared (so they never land in the frontmatter), and a conditional field's required rule applies only while it is visible.
fields:
- { name: hasCta, label: Add a call to action?, type: boolean }
- name: ctaUrl
label: Button URL
type: url
required: true
showIf: { field: hasCta, truthy: true } # equals / oneOf also workNested collections
Set nested: true on a folder collection to also gather entries from subfolders (up to five levels deep) and show them grouped per folder — handy for documentation trees. Search and relation pickers pick the nested entries up automatically.
collections:
- name: docs
label: Documentation
type: folder
folder: content/docs
nested: true # gather entries from subfolders too
fields:
- { name: title, type: string, required: true }
- { name: body, type: markdown }Reusable field components
Define a named field group once under the top-level components map, then drop it into any collection with a component field. It expands to a regular object group when loaded, so a change to the group updates every collection that uses it.
components:
callToAction:
- { name: label, type: string }
- { name: url, type: url }
collections:
- name: pages
type: folder
folder: content/pages
fields:
- { name: title, type: string, required: true }
- { name: cta, type: component, component: callToAction }Where media lives (and why not S3)
Uploads are committed to your repository, next to the content that references them. That is a deliberate choice, not a missing feature: the repo stays the single source of truth, images travel with their posts through branches, review and history, and cloning the repo gives you the complete site — no second system to back up or pay for.
Need a CDN or image resizing? Let your generator or host handle it at build time (Astro's and Next.js's image pipelines, Netlify image CDN, Cloudinary fetch URLs). PullPress keeps the original in Git; your build makes it fast. Uploads are compressed client-side and capped at a few megabytes, which in practice keeps repos comfortably small. External object storage (S3 and friends) would break the "your repo is everything" guarantee, so we don't plan it.