Our SCSS is split into three folders and compiled by tools inside Docker, so developers do not need Sass installed on the host. The source and generated files still live on a bind-mounted project directory. Here's the setup and why it matters.
The Directory Structure
assets/scss/site/ ├── common/ ← variables, breakpoints, shared partials ├── mobile/ ← base styles and small-screen adjustments │ ├── _catalog_list.scss │ ├── _catalog_item.scss │ ├── _account.scss │ └── _main.scss └── desktop/ ← wider-screen overrides ├── _catalog_list.scss ├── _catalog_item.scss └── _main.scss The mobile folder contains the base appearance. It still has a few targeted media and accessibility queries; “mobile-first” does not have to mean “no media queries.”
The desktop folder primarily contains wider-screen overrides. If a component does not change at the desktop breakpoint, it usually needs no matching desktop partial.
Why This Matters
The separation makes the cascade and ownership clear: base rules live in one place, and desktop files describe the delta. We compile separate outputs and apply the desktop stylesheet conditionally:
<link rel="stylesheet" href="/storage/static/css/site/main_catalog_list_mobile.css"> <link rel="stylesheet" href="/storage/static/css/site/main_catalog_list_desktop.css" media="(min-width: 641px) and (min-height: 500px)"> The media attribute controls when the desktop CSS applies. Browsers may fetch a nonmatching stylesheet at lower priority, but it does not guarantee that the file will not be downloaded. Verify transfer behavior with browser tooling instead of treating file separation as an automatic bandwidth saving.
Building Inside Docker
We never run sass on the host machine. The Docker container has Dart Sass installed:
# docker-compose.yml (simplified) site: build: context: . dockerfile: .docker/site/site.dockerfile command: ["make", "-f", "/Makefile", "dev"] volumes: - ./src:/app - ./Makefile:/Makefile The development target builds assets once, then runs Air and Sass concurrently:
dev: build-css-dev build-js-dev $(MAKE) -j 2 run-air watch-css run-air: cd $(SRC_DIR) && air -c .air.site.toml watch-css: sass --watch --poll $(SCSS_INPUTS) --style=expanded --no-source-map We use --poll because filesystem event delivery from bind mounts varies across host and Docker combinations. Polling is not universally required, so test both modes in your own environment.
Compiled Output
storage/static/css/site/ ├── main_catalog_list_mobile.css ├── main_catalog_list_desktop.css ├── main_catalog_item_mobile.css ├── main_catalog_item_desktop.css ├── main_account_mobile.css ├── main_account_desktop.css ├── bundle_mobile.css └── bundle_desktop.css Our static web server serves the compiled files directly, without routing them through the Go application.
The Gotcha: Air Doesn't Watch SCSS
Air's config explicitly excludes assets/ and storage/:
[build] exclude_dir = ["assets", "storage", "tmp"] This is intentional. SCSS changes are handled by sass --watch, which runs in parallel. If Air watched SCSS files too, it would rebuild the Go binary on every CSS edit — pointless and slow.
JavaScript has no watcher in this setup. After editing an entry file, we run:
docker compose exec -T site make -f /Makefile build-js-dev This is a deliberate project trade-off, not a requirement of Docker or Sass.
What We'd Change
If starting over, we would evaluate native CSS plus Lightning CSS, or keep Sass and add a separate post-processing step. Lightning CSS can transform, prefix, bundle, and minify CSS, but it is not a drop-in compiler for SCSS syntax.
Dart Sass remains a good fit for the current partials, and keeping the toolchain in Docker gives every developer the same compiler version.
This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.

