14 Apr.2026

One Gulp File to Rule Them All: Centralized CSS Compilation for Drupal Custom Themes and Modules

gulpfile.js for Drupal

The Problem Every Drupal Developer Knows

On a typical Drupal project with a custom theme and several custom modules, the asset pipeline tends to sprawl. Each module that ships its own CSS gets its own gulpfile.js, its own package.json, and its own node_modules directory. A project with one theme and five custom modules ends up with six independent build systems — six places to update dependencies, six watch processes to juggle, and six points of failure.

This is waste in its most visible form.

The Solution: One gulpfile.js at the Project Root

The approach described here consolidates every SCSS compilation task — for the custom theme and every custom module — into a single gulpfile.js that lives at the repository root. No more per-directory build files. One npm install, one gulp watch, one place to update Autoprefixer.

The trick is a lightweight local configuration file (config.local.js) that each developer copies and edits once, then never thinks about again.

File Layout

project-root/
├── config.local.js          ← your local settings (git-ignored)
├── config.local.js.example  ← committed template
├── gulpfile.js              ← the single build file
├── package.json
└── web/
    ├── themes/custom/
    │   └── ahu/
    │       ├── assets/scss/
    │       └── dist/css/
    └── modules/custom/
        ├── my_module_a/
        │   ├── assets/scss/
        │   └── dist/css/
        └── my_module_b/
            ├── assets/scss/
            └── dist/css/

The gulpfile.js discovers every module under web/modules/custom/ that contains an assets/ directory and compiles it automatically. Adding a new module requires zero changes to the build system.

Step 1 — Install Dependencies Once

npm install

The package.json at the root declares everything that is needed:

package.json

{
    "name": "makedrupaleasy",
    "version": "2.0.1",
    "author": "Ruslan Piskarov",
    "homepage": "http://www.makedrupaleasy.com",
    "private": true,
    "scripts": {
        "livereload": "livereload --host 127.0.0.1 --exclusions node_modules/ --exts 'css,js,twig,theme,apng,avif,gif,jpg,jpeg,jfif,pjpeg,pjp,png,svg,webp'"
    },
    "devDependencies": {
        "autoprefixer": "^10.4.23",
        "bootstrap": "^5.3.8",
        "gulp": "^5.0.1",
        "gulp-postcss": "^10.0.0",
        "gulp-sass": "^6.0.1",
        "livereload": "^0.10.3",
        "postcss": "^8.5.6",
        "sass": "^1.97.3"
    },
    "main": "gulpfile.js",
    "browserslist": [
        "last 2 version",
        "> 1%",
        "maintained node versions",
        "not dead"
    ]
}
PackageRole
gulp v5Task runner
gulp-sass + sassDart Sass SCSS compilation
gulp-postcss + autoprefixerVendor-prefix injection
livereloadBrowser live-reload during development
bootstrapAvailable as an SCSS import source

 

Step 2 — Create Your Local Configuration

Copy the example file and edit it once:

cp config.local.js.example config.local.js

config.local.js.example is committed to the repository as a safe, generic template:

config.local.js.example

/**
 * Local configuration for gulp tasks.
 *
 * Copy this file to config.local.js and modify as needed.
 * The config.local.js file will be ignored by git.
 */

module.exports = {
  // Local domain for the project.
  localDomain: 'http://localhost',

  // Docroot directory.
  docroot: './web',

  // Theme configuration.
  theme: {
    // Name of the custom theme.
    name: 'my_theme',
  },
};

config.local.js is git-ignored and holds your actual environment values:

config.local.js

/**
 * Local configuration for gulp tasks.
 *
 * Copy this file to config.local.js and modify as needed.
 * Git will ignore the config.local.js file.
 */

module.exports = {
  // Local domain for the project.
  localDomain: 'https://ahu.ddev.site',

  // Docroot directory.
  docroot: './web',

  // Theme configuration.
  theme: {
    // Name of the custom theme.
    name: 'ahu',
  },
};

The file is intentionally minimal. The only values that vary between developers are the local domain and the theme name. Everything else — paths to modules, SCSS patterns, output structure — is derived automatically inside gulpfile.js.

 

Step 3 — The gulpfile.js in Detail

gulpfile.js

/**
 * This gulpfile.js is for building custom theme and custom modules.
 * From now, not necessary to have a similar gulpfile.js
 * for a custom theme and each custom module.
 * Before running this file, create a config.local.js.
 * See config.local.js.example for an example.
 *
 * With best wishes, Ruslan Piskarov.
 */

'use strict';

const gulp = require('gulp'),
  sass = require('gulp-sass')(require('sass')),
  postcss = require('gulp-postcss'),
  autoprefixer = require('autoprefixer'),
  fs = require('fs'),
  path = require('path');

// Load a local configuration if it exists.
let localConfig = {};
const localConfigPath = './config.local.js';
if (fs.existsSync(localConfigPath)) {
  localConfig = require(localConfigPath);
}

// Define paths with defaults that can be overridden by local config.
const docroot = localConfig.docroot || './web';
const themePath = path.join(docroot, 'themes/custom');
const modulesPath = path.join(docroot, 'modules/custom');

// Get all custom modules with assets directory.
const getModulesWithAssets = () => {
  const modules = [];

  // Check if the modules directory exists.
  if (fs.existsSync(modulesPath)) {
    const modulesList = fs.readdirSync(modulesPath);

    modulesList.forEach(moduleName => {
      const assetsPath = path.join(modulesPath, moduleName, 'assets');
      if (fs.existsSync(assetsPath)) {
        modules.push({
          name: moduleName,
          path: path.join(modulesPath, moduleName),
          assetsPath: assetsPath,
          publicPath: path.join(modulesPath, moduleName, 'dist'),
        });
      }
    });
  }

  return modules;
};

// Theme configuration.
const themeName = (localConfig.theme && localConfig.theme.name)
  ? localConfig.theme.name
  : 'my_theme';

const theme = {
  name: themeName,
  path: path.join(themePath, themeName),
  assetsPath: path.join(themePath, themeName, 'assets'),
  publicPath: path.join(themePath, themeName, 'dist'),
};

// Get modules with assets.
const modules = getModulesWithAssets();

// Compile SCSS for theme.
async function compileThemeSass() {
  return gulp.src([
    path.join(theme.assetsPath, 'scss/*.scss'),
    path.join(theme.assetsPath, 'scss/*/*.scss'),
  ], { sourcemaps: true })
    .pipe(sass({
      outputStyle: 'expanded',
      precision: 10,
      quietDeps: true,
      silenceDeprecations: ['import', 'legacy-js-api'],
    }).on('error', sass.logError))
    .pipe(postcss([
      autoprefixer({
        overrideBrowserslist: ['last 2 versions'],
        grid: true,
        remove: false,
      }),
    ]))
    .pipe(gulp.dest(
      path.join(theme.publicPath, 'css'),
      { sourcemaps: '../sourcemaps' }
    ));
}

// Compile SCSS for modules.
async function compileModulesSass() {
  if (modules.length === 0) {
    return Promise.resolve();
  }

  const tasks = modules.map(module => {
    // Check if the scss directory exists.
    const scssDir = path.join(module.assetsPath, 'scss');
    if (!fs.existsSync(scssDir)) {
      return Promise.resolve();
    }

    // Create a dist directory if it doesn't exist.
    if (!fs.existsSync(module.publicPath)) {
      fs.mkdirSync(module.publicPath, { recursive: true });
    }

    // Create a CSS directory if it doesn't.
    const cssDir = path.join(module.publicPath, 'css');
    if (!fs.existsSync(cssDir)) {
      fs.mkdirSync(cssDir, { recursive: true });
    }

    return gulp.src([
      path.join(module.assetsPath, 'scss/*.scss'),
      path.join(module.assetsPath, 'scss/*/*.scss'),
    ], { sourcemaps: true })
      .pipe(sass({
        outputStyle: 'expanded',
        precision: 10,
        quietDeps: true,
        silenceDeprecations: ['import', 'legacy-js-api'],
      }).on('error', sass.logError))
      .pipe(postcss([
        autoprefixer({
          overrideBrowserslist: ['last 2 versions'],
          grid: true,
          remove: false,
        }),
      ]))
      .pipe(gulp.dest(
        path.join(module.publicPath, 'css'),
        { sourcemaps: '../sourcemaps' }
      ));
  });

  return Promise.all(tasks);
}

// Compile all SCSS.
async function compileSass() {
  await compileThemeSass();
  await compileModulesSass();
  return Promise.resolve();
}

// Watch for changes.
async function watch() {
  // Watch theme files.
  gulp.watch([
    path.join(theme.assetsPath, 'scss/*.scss'),
    path.join(theme.assetsPath, 'scss/*/*.scss'),
  ], compileThemeSass);

  // Watch module files.
  modules.forEach(module => {
    const scssDir = path.join(module.assetsPath, 'scss');
    if (fs.existsSync(scssDir)) {
      gulp.watch([
        path.join(scssDir, '*.scss'),
      ], compileModulesSass);
    }
  });
}

// Define tasks.
gulp.task('sass', compileSass);
gulp.task('watch', watch);

gulp.task('default', gulp.series('watch'));

How the Auto-Discovery Works

The getModulesWithAssets() function is the heart of the approach. At startup it reads the web/modules/custom/ directory and collects every subdirectory that contains an assets/ folder:

const getModulesWithAssets = () => {
  const modules = [];

  if (fs.existsSync(modulesPath)) {
    fs.readdirSync(modulesPath).forEach(moduleName => {
      const assetsPath = path.join(modulesPath, moduleName, 'assets');
      if (fs.existsSync(assetsPath)) {
        modules.push({
          name: moduleName,
          path: path.join(modulesPath, moduleName),
          assetsPath,
          publicPath: path.join(modulesPath, moduleName, 'dist'),
        });
      }
    });
  }

  return modules;
};

Each discovered module receives four derived paths:

PropertyValue
nameDirectory name, e.g. my_module
pathweb/modules/custom/my_module
assetsPathweb/modules/custom/my_module/assets
publicPathweb/modules/custom/my_module/dist

SCSS is always expected at assets/scss/ and output always lands at dist/css/. This convention is enforced once, here, globally.


Graceful Fallbacks

The configuration loading uses fs.existsSync before require(), so the file works even when config.local.js is absent — for example, in CI:

let localConfig = {};
const localConfigPath = './config.local.js';
if (fs.existsSync(localConfigPath)) {
  localConfig = require(localConfigPath);
}

const docroot = localConfig.docroot || './web';

The theme name falls back to 'my_theme' when the config is missing:

const themeName = (localConfig.theme && localConfig.theme.name)
  ? localConfig.theme.name
  : 'my_theme';

SCSS Compilation Settings

Both the theme and module tasks share identical Sass settings:

.pipe(sass({
  outputStyle: 'expanded',              // human-readable; minify in a CI step
  precision: 10,                        // decimal places for calculated values
  quietDeps: true,                      // suppress deprecation noise from deps
  silenceDeprecations: ['import', 'legacy-js-api'],
}).on('error', sass.logError))

silenceDeprecations: ['import', 'legacy-js-api'] is significant. Dart Sass 1.x has deprecated the @import rule and the legacy JavaScript API. These flags keep the build output clean while a migration to @use / @forward is in progress.

Autoprefixer follows immediately via PostCSS:

.pipe(postcss([
  autoprefixer({
    overrideBrowserslist: ['last 2 versions'],
    grid: true,    // emit -ms-grid prefixes for IE 11 Grid support
    remove: false, // never strip existing prefixes
  }),
]))

Source Maps

Both the theme and module tasks write source maps to a sibling sourcemaps/ directory:

.pipe(gulp.dest(
  path.join(theme.publicPath, 'css'),
  { sourcemaps: '../sourcemaps' }
))

The output tree for the theme looks like this:

web/themes/custom/ahu/dist/
├── css/
│   ├── style.css
│   └── components/
│       └── card.css
└── sourcemaps/
    ├── style.css.map
    └── components/
        └── card.css.map

The watch Task

The watcher registers file system observers for every path it knows about at startup — theme SCSS and each discovered module's SCSS directory:

async function watch() {
  gulp.watch([
    path.join(theme.assetsPath, 'scss/*.scss'),
    path.join(theme.assetsPath, 'scss/*/*.scss'),
  ], compileThemeSass);

  modules.forEach(module => {
    const scssDir = path.join(module.assetsPath, 'scss');
    if (fs.existsSync(scssDir)) {
      gulp.watch([path.join(scssDir, '*.scss')], compileModulesSass);
    }
  });
}

gulp.task('default', gulp.series('watch')) means simply running gulp starts the watcher.

 

Available Commands

# Start watch mode (default)
npx gulp

# Watch and compile all SCSS
gulp watch

# Compile all SCSS once
npx gulp sass

# Run live-reload server (separate terminal)
npm run livereload

 

Adding a New Custom Module

Convention is the only requirement. Create the directory structure and the build system picks it up on the next gulp invocation:

web/modules/custom/my_new_module/
└── assets/
    └── scss/
        └── my_new_module.style.scss

Nothing else changes. No gulpfile.js in the module. No new package.json. The dist/css/ directory is created automatically if it does not exist.

 

Developer Onboarding Checklist

  1. Clone the repository.
  2. Run npm install at the project root.
  3. Copy config.local.js.example to config.local.js.
  4. Set localDomain to your local URL (e.g., https://project.ddev.site).
  5. Set theme.name to the custom theme directory name.
  6. Run npx gulp to start the watcher.

That is the complete setup — for the theme and every current and future custom module.

 

Conclusion

The sprawl of per-directory build files is a solved problem. A single gulpfile.js at the project root, paired with a git-ignored config.local.js, gives every developer on the team one consistent place to compile SCSS — for the custom theme and for every custom module, present and future.

The key insights that make it work:

  • Convention over configuration. Modules are discovered automatically by the presence of an assets/ directory. No registration, no manifest — just a folder.
  • Local config is ephemeral. The config.local.js file is git-ignored by design. It holds only what is genuinely local — the domain and the theme name. Everything else is derived.
  • Graceful degradation in CI. When config.local.js is absent, the build falls back to safe defaults. The pipeline never breaks because a developer's personal config is missing.
  • One dependency tree. A single node_modules at the root means one npm install, one version of Autoprefixer, one version of Dart Sass — across the entire project.

The next time a new custom module needs styles, the answer is simple: create assets/scss/, write SCSS, run npx gulp. The build system already knows what to do.

Ruslan Piskarov

Ukraine
PHP/WEB Developer / Drupal Expert. More than 11 years of experience delivering Drupal based General Purpose solutions for different sectors such as Job Boards, Product Portfolios, Geo Coding, Real Estate solutions, E-Commerce, Classifieds, Corporate and online Magazines/Newspapers.