How We Built It, Part 2: Recipes 101 and Branding
In Part 1 you ran make build and watched it apply "the recipes in order." That command is doing the most important work in the whole project, so this post opens the hood. We'll cover what a Drupal Recipe actually is, how the two recipes layer to build the site, and then walk through the first thing the recipe touches: turning the stock theme into the Gig City brand.
What a Drupal Recipe actually is
A Drupal Recipe is a folder of declarative YAML that describes site structure — content types, fields, views, menus, pages, theme settings — plus an optional cleanup script. Instead of clicking through admin screens to build the site (which leaves no record of what you did or why), you write the structure down as files in git, and Drupal applies them.
Our recipe lives at recipes/chattanooga_digital/. The entry point is one file, recipe.yml, with three sections that do all the heavy lifting:
recipes:— other recipes to apply first. Ours lists exactly one:byte(the upstream SaaS-style base theme and its components). This is how recipes compose — you build on top of someone else's.install:— the modules to enable. Ours lists the things the site genuinely needs:smart_dateandsmart_date_recur(real date fields with recurrence),feedsandfeeds_ical(importing external calendars),fullcalendar_view,honeypot(spam protection),easy_breadcrumbandmenu_breadcrumb, and several small custom modules likecd_event_ics.config:— the settings to change. Each entry names a Drupal config object and patches specific keys. This is where the site identity, page cache, timezone, permissions, and field settings all get pinned.
The single most important thing to understand: this YAML is the source of truth, not the running database. If you change the site by clicking around in the admin UI, that change vanishes the next time the site is rebuilt from the recipe. Every real change lives in a recipe file (or in the theme source). That discipline is what makes the site reproducible from git — the promise we made back in Part 0.
The build order: byte, then chattanooga_digital, then cleanup
You don't run these commands by hand — make build runs the whole sequence for you, and the same sequence runs identically on staging and production (via a service called fix-settings), so the site is built the same way everywhere. Conceptually the core of it is three steps:
drush recipe byte # 1. upstream base theme + components
drush recipe chattanooga_digital # 2. our rebrand + structure + content
drush php:script recipes/chattanooga_digital/post_install.php # 3. cleanupIn practice make build wraps those three in a few guardrails that make the build reliable on a truly fresh volume — worth knowing about, because they're the difference between "it built" and "it half-built and failed confusingly." The real sequence is:
- Declare the contrib packages, then update. The stock container's
composer.jsondoesn't list the modules our recipe needs (feeds, smart_date, fullcalendar_view, and a dozen more), somake buildfirstcomposer require --no-updates them (pinning Drupal core to the 11.3 line), authorizes the composer plugins the update pulls in, then runs one unconstrainedcomposer update. A barecomposer updatewithout the require step silently skips the un-declared modules, and the recipe later fails with "module is not a known module." - Guard that the modules actually landed — a fail-loud check that every recipe-required module is on disk before the recipe runs, so a silently-skipped composer step can't fail later with a misleading error.
drush updatedb— apply any pending database updates.- Reconcile Canvas components, then the Byte recipe. Our forked theme's components have drifted from the version hashes the image-baked Byte recipe pins, so a regen step runs before (and after)
drush recipe byte, with a one-shot retry for a known cold-start race. drush recipe chattanooga_digital— our rebrand, content types, pages, calendar, forms.post_install.php— run twice. The cleanup below runs once, then again, because the Byte recipe creates some content asynchronously and can win a race against the first cleanup pass.
You never have to memorize that — make build is the command. But it's why the honest answer to "can I just run drush recipe myself?" is "run make build; it does the recipe plus the plumbing that keeps a from-scratch build from breaking." The full, current sequence always lives in the Makefile's build target.
Why the cleanup step at all? Because recipes are additive by design — they import config and content, but they have no clean way to delete something an upstream recipe shipped. The Byte recipe ships a stock demo homepage, a handful of placeholder marketing pages, some "expense visibility" blog posts, and a default main menu. We don't want any of that, but the recipe API can't remove it.
That's the job of post_install.php. It runs once after the recipe and does the deletions and pointer-setting the recipe can't express:
- Sets the front page to our homepage by looking it up via its stable UUID, then writing
system.site:page.front. - Deletes the stock Byte demo Canvas pages (anything not matching our titles like "Chattanooga", "Programs", "Workshop", "Blog").
- Deletes the stock placeholder blog posts and the stock main-menu links, keeping only the menu links our recipe ships.
The whole script is written to be idempotent — safe to run over and over. It looks entities up by UUID, and short-circuits if the desired state is already in place. That matters because on staging and production it runs on every deploy, not just the first.
What a Recipe can't do (and where that goes instead)
The split is worth stating plainly, because it's the rule for the rest of this series:
- Anything the recipe API can express — a content type, a field, a view, a menu link, a config value — goes in a recipe YAML file under
recipes/chattanooga_digital/. - Ad-hoc cleanup the API can't express — deleting an upstream entity, setting the front page, reparenting a menu — goes in
post_install.php. - Brand and markup — colors, fonts, templates — live in the theme source under
themes/byte_theme/, not in the recipe at all.
The nine old imperative PHP "build scripts" this project used to have were deleted precisely because they blurred that line. Hand-rolled UUIDs and version-pinned component hashes rot fast across upstream upgrades. The declarative recipe doesn't.
Branding: a few CSS files turn stock Byte into Gig City
Now the fun part. Byte ships as a dark-mode SaaS template — Roboto type, a generic blue/orange palette. We rebrand it without subtheming (Byte's own docs say don't subtheme) by editing the CSS source files Byte officially supports for customization, all under themes/byte_theme/src/. Three of them carry the core brand:
theme.css— color tokens, typography stack, radius, shadows.fonts.css— the@font-face/@importfont declarations.main.css— body-level overrides, heading families, the grain texture, text selection.
A handful of smaller skin files live alongside them for specific surfaces (cd-footer-skin.css, cd-services-grid.css, cd-contrast-fixes.css, cd-events-filter.css) — same idea, narrower scope. The three above are where the brand actually lives.
The colors are the heart of it. The design direction defines nine named "Gig City" tokens — each with a specific Chattanooga referent — and maps them onto Byte's semantic slots inside theme.css (the core ones):
:root {
--cd-fiber-blue: #0A4D8C; /* Walnut Street Bridge blue, EPB fiber */
--cd-grid-teal: #0D7377; /* smart-grid switch / link accent */
--cd-signal-green: #7AB648; /* fiber light, success, growth */
--cd-bridge-copper: #B87333; /* patina copper — CTAs, warm accent */
--cd-river-slate: #2C3E50; /* body text, ink */
--cd-limestone: #F5F1EB; /* page background — warm off-white */
--cd-deep-current: #1A252F; /* river at night, footer */
--background: var(--cd-limestone);
--foreground: var(--cd-river-slate);
--primary: var(--cd-fiber-blue);
--secondary: var(--cd-grid-teal);
}That's the trick: the page never references a raw hex value. The brand tokens (--cd-*) feed Byte's existing semantic slots (--background, --primary, and so on), so every button, link, and surface across the whole theme picks up the Gig City palette at once. The blue is Walnut Street Bridge blue, the green is fiber light, the copper is the patina on the bridge ironwork — every token has a real referent, not a Pantone fashion pick.
Typography is three open-source faces, loaded from Bunny Fonts (a privacy-respecting Google Fonts mirror — no cookies, no logging) with a single import line in fonts.css:
@import url("https://fonts.bunny.net/css?family=fraunces:400,500,600,700,800,900|inter:400,500,600,700,800|jetbrains-mono:400,500,600&display=swap");Inter carries body and UI, JetBrains Mono handles code, and Fraunces — a variable serif with an optical-sizing axis — does the editorial headings. The variable axis is what lets the same font feel sharp at body size and hand-crafted at billboard size, so the hero headline doesn't read as template-default.
Rebuild and refresh
CSS edits aren't live until Tailwind recompiles the bundle (build/main.min.css) and Drupal clears its CSS aggregation cache. Two Makefile targets do exactly that, or one combined target:
make theme-build # recompile build/main.min.css via a one-off node container
make cr # drush cr — clear Drupal's caches
make theme-refresh # both of the above in one goThen hard-refresh the browser (Ctrl+Shift+R) so it drops the old aggregated bundle. The same edits propagate to staging and production for free — there's nothing extra to run. Because the brand CSS lives in git and the fix-settings service recompiles Tailwind on every deploy, a git push to main is all it takes for the next deploy to carry the new brand.
What's next
You now know what a recipe is, how the two recipes layer, what post_install.php mops up, and how the brand lives in a few CSS files. In Part 3 we go up a level from CSS to components — building the reusable SDC pieces (hero, billboard, cards) that the Canvas pages are assembled from.
Beta-test this post with your AI
This series is open-source documentation, and documentation drifts. Help us keep it honest: point your AI coding assistant (Cursor, Claude Code, or similar) at this post and the repo, and if it finds something that no longer matches the code, open a ticket. First time? Read A Token Is an Identity (in this series) so your AI opens issues as you, with credit. Edit the three variables at the top of the block below, then paste it into your assistant:
# ─────── EDIT THESE FIRST ───────
GITHUB_USERNAME = "your-github-username" # your handle, so the issue is credited to YOU (see the Token Is an Identity post)
DEMO_REPO = "TortoiseWolfe/gig-city-drupal" # the repo to file against — or your own fork: GITHUB_USERNAME/gig-city-drupal
ARTICLE_URL = "https://www.chattanooga.digital/blog/2026-06/how-we-built-it-part-2-recipes-101-and-branding"
# ────────────────────────────────
Read ARTICLE_URL, then clone DEMO_REPO into my local environment and explore it.
Cross-check Part 2 (Recipes 101 and Branding) against the actual code — the recipe structure, the config-vs-content split, and the branding steps.
If anything is inaccurate, out of date, or contradicts the code, open an issue on
DEMO_REPO (authenticated as GITHUB_USERNAME) so the maintainers can fix the tutorial.
A good issue:
- one problem per issue (not a catch-all list)
- a clear title naming the section, e.g. "Part N: no longer matches the code"
- cite the exact spot: the post section + the repo file/path (and line if you can)
- say what the post claims vs. what the code actually does
- suggest the fix if it's obvious← Part 1: Install It in Docker · Part 3: Building Components →
More insights
Want updates from Chattanooga.Digital?
Pre-join the co-op to receive new posts, workshop schedules, and member updates.