Efficient Management of Static Assets: Build, Deploy, and Release

Table of Contents

Static Assets Build, Deploy, and Release

Background

Frequently static resources are deployed in the following ways:

  • HEAD of VC
  • tag of VC
  • RPM of build based on HEAD of VC

This will cover using git-flow as the baseline release tool with deployment integrated into the Grunt build tool and Puppet.

Goal

Reduce the communication costs associated with when and what to build and how to deploy to a staging environment. The following should be triggers:

  • on the merge of a release into master stage should be deployed
  • on updates in develop the test environment should be updated
  • production release should pull the stable version on stage

Setup

This assume you're running

  • static resources stored in git
  • git-flow
  • Grunt
  • Puppet

Work-flow

    git flow init
    git flow release start 0.0.1

Diagram

The 2012 release pipeline as drawn at the time: nvie's git-flow branch model on the left, Grunt building artifacts in the middle, Puppet provisioning environments on the right. The branch a commit lands on determines which environment Puppet promotes the artifact to.

// Grunt + git-flow + Puppet — 2012 release pipeline as drawn at the time
digraph grunt_git_flow {
    rankdir=LR;
    graph [bgcolor="white", fontname="Helvetica", fontsize=11,
           pad="0.3", nodesep="0.3", ranksep="0.35"];
    node  [shape=box, style="rounded,filled", fontname="Helvetica",
           fontsize=10, fillcolor="#f5f5f5", color="#888"];
    edge  [color="#aaa"];

    // Branch model cluster — nvie git-flow
    subgraph cluster_branches {
        label="git-flow branch model (nvie, 2010)"; labeljust="l";
        color="#369"; fontcolor="#369"; style="rounded,dashed";
        feature  [label="feature/*",   color="#369", fontcolor="#369"];
        develop  [label="develop",     color="#369", fontcolor="#369"];
        release  [label="release/*",   color="#369", fontcolor="#369"];
        master   [label="master\n(tagged)", color="#369", fontcolor="#369"];
        hotfix   [label="hotfix/*",    color="#369", fontcolor="#369"];
    }

    // Build step — Grunt task runner
    grunt    [label="Grunt\n(concat, uglify,\nless, imagemin)",
              color="#693", fontcolor="#693", fillcolor="#f0f7e8"];
    artifact [label="versioned artifact\n(static bundle / RPM)",
              color="#693", fontcolor="#693"];

    // Provisioning cluster — Puppet pulls artifacts to environments
    subgraph cluster_provisioning {
        label="Puppet provisioning"; labeljust="l";
        color="#d63"; fontcolor="#d63"; style="rounded,dashed";
        puppet   [label="Puppet master\n(manifests + facts)",
                  color="#d63", fontcolor="#d63"];
        test     [label="test env",    color="#d63", fontcolor="#d63"];
        staging  [label="staging env", color="#d63", fontcolor="#d63"];
        prod     [label="production",  color="#d63", fontcolor="#d63",
                  fillcolor="#fde8e8"];
    }

    // Branch model flow
    feature -> develop [label="merge"];
    develop -> release [label="release start", color="#693", fontcolor="#693"];
    release -> master  [label="release finish\n(tag vX.Y.Z)", color="#693", fontcolor="#693"];
    hotfix  -> master  [label="hotfix finish", color="#d36", fontcolor="#d36"];
    hotfix  -> develop [label="back-merge", style=dashed];

    // Builds — branch landing triggers Grunt
    develop -> grunt [label="build:dev",  color="#693", fontcolor="#693"];
    release -> grunt [label="build:rc",   color="#693", fontcolor="#693"];
    master  -> grunt [label="build:prod", color="#693", fontcolor="#693"];
    grunt   -> artifact;

    // Provisioning — Puppet pulls the artifact, branch decides target
    artifact -> puppet [color="#d63"];
    puppet   -> test    [label="develop -> test",     color="#d63", fontcolor="#d63"];
    puppet   -> staging [label="release -> staging",  color="#d63", fontcolor="#d63"];
    puppet   -> prod    [label="master -> production", color="#d63", fontcolor="#d63"];
}

diagram-grunt-git-flow.png

Related notes

Context (2012)

"Deploying static assets" in 2012 was largely a manual exercise wrapped in just enough automation to be repeatable. The pattern at most shops looked something like this: a designer or front-end developer committed updated CSS/JS/images to a Subversion or (newly) Git repository; an operator either scp'd the tree to a production host or, in more disciplined shops, an RPM was built from a tagged commit and pushed through Spacewalk or a homegrown yum repo. CDNs existed — Akamai had been around since the late 1990s, CloudFront launched in 2008 — but they were not the default for a typical mid-sized web property; it was still ordinary to serve /static/ straight off an Apache 2.2 or nginx 1.0 host sitting in your own rack or on a DreamHost / Linode / EC2 small instance. The "static asset host" was a single VM, the artifact was a directory tree, and the deploy was a file copy plus an Apache reload.

The "communication cost" the original note alludes to was the friction layer wrapped around that file copy. At an enterprise the path from "designer pushes CSS" to "CSS is live" routinely included: a Jira ticket to ops, a CAB (change advisory board) review for the production window, a manual scheduling email, a smoke-test sign-off from QA, and an after-hours deploy slot — all to ship a few hundred kilobytes of text. The git-flow + Grunt + Puppet stack was an attempt to replace those human handoffs with a branch-driven contract: the branch a commit lands on names the environment it goes to, and the build + provisioning toolchain executes that contract without further negotiation.

Period-relevant code

The artifacts below are the kind of files a 2012 contractor would actually have committed for this stack. They are illustrative, not canonical — but they should look familiar to anyone who shipped front-end code in the Grunt era.

Gruntfile.js (Grunt 0.4.x, late 2012)

Grunt 0.4 was the dominant version through 2013–2015; 1.0 did not ship until April 2016. The grunt.loadNpmTasks(...) line for every plugin is the giveaway — load-grunt-tasks existed but wasn't yet universal, and package.json scripts had not yet displaced the task runner.

// Gruntfile.js — Grunt 0.4.x, circa late 2012
module.exports = function(grunt) {
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),

    less: {
      dist: {
        options: { compress: true, cleancss: true },
        files: { 'build/css/app.css': 'src/less/app.less' }
      }
    },

    concat: {
      js: {
        src: [
          'src/js/vendor/jquery-1.8.3.js',
          'src/js/vendor/underscore-1.4.3.js',
          'src/js/vendor/backbone-0.9.10.js',
          'src/js/app/**/*.js'
        ],
        dest: 'build/js/app.js'
      }
    },

    uglify: {
      options: { banner: '/*! <%= pkg.name %> <%= pkg.version %> */\n' },
      dist: { files: { 'build/js/app.min.js': ['build/js/app.js'] } }
    },

    imagemin: {
      dist: {
        files: [{
          expand: true, cwd: 'src/img/',
          src: ['**/*.{png,jpg,gif}'], dest: 'build/img/'
        }]
      }
    },

    watch: {
      less: { files: ['src/less/**/*.less'], tasks: ['less'] },
      js:   { files: ['src/js/**/*.js'],     tasks: ['concat', 'uglify'] }
    }
  });

  grunt.loadNpmTasks('grunt-contrib-less');
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-imagemin');
  grunt.loadNpmTasks('grunt-contrib-watch');

  grunt.registerTask('default', ['less', 'concat', 'uglify', 'imagemin']);
  grunt.registerTask('build:dev',  ['less', 'concat']);
  grunt.registerTask('build:prod', ['default']);
};

git-flow worked release (nvie's git-flow tool)

Vincent Driessen's git-flow tool wraps the branch-and-merge dance from his January 2010 blog post into a small set of commands. The release finish step is where the model earns its keep — it merges into both master and develop, tags master, and deletes the release branch, all in one transaction.

# One-time per repo
git flow init -d                    # accept all defaults: master/develop/feature/release/hotfix

# Cut a release from develop
git flow release start 1.4.0
# ...bump version in package.json, update CHANGELOG, commit...
git flow release finish 1.4.0       # merges into master + develop, tags v1.4.0
git push origin develop master --tags

# Hotfix straight off master
git flow hotfix start 1.4.1
# ...fix the bug, commit...
git flow hotfix finish 1.4.1        # merges into master + develop, tags v1.4.1

A typical ~/.gitconfig fragment of the era:

[gitflow "branch"]
    master = master
    develop = develop
[gitflow "prefix"]
    feature = feature/
    release = release/
    hotfix = hotfix/
    support = support/
    versiontag = v
[alias]
    lg = log --graph --oneline --decorate --all
    st = status -sb

Puppet 3.x manifest for an nginx static host

Puppet 3.x (released August 2012) introduced the parser improvements and Hiera integration that made Forge modules ergonomic. The james/nginx and jfryman/nginx modules were the popular picks for this exact use case before the puppet/nginx (Vox Pupuli) consolidation.

# /etc/puppet/modules/static_assets/manifests/init.pp
class static_assets (
  $version    = '1.4.0',
  $artifact   = '/var/cache/static-assets',
  $docroot    = '/var/www/static',
  $vhost_name = 'static.example.com',
) {

  class { '::nginx':
    worker_processes    => $::processorcount,
    worker_connections  => '1024',
    sendfile            => 'on',
    gzip                => 'on',
  }

  package { 'static-assets':
    ensure   => $version,
    provider => 'rpm',
    source   => "${artifact}/static-assets-${version}.noarch.rpm",
  }

  file { $docroot:
    ensure  => directory,
    owner   => 'nginx',
    group   => 'nginx',
    mode    => '0755',
    require => Package['static-assets'],
  }

  nginx::resource::vhost { $vhost_name:
    ensure              => present,
    listen_port         => 80,
    www_root            => $docroot,
    location_cfg_append => {
      'expires'         => '30d',
      'add_header'      => 'Cache-Control "public, immutable"',
    },
  }
}

# site.pp
node /^static\d+\.example\.com$/ {
  class { 'static_assets':
    version => hiera('static_assets::version', '1.4.0'),
  }
}

Capistrano deploy.rb (Capistrano 2.x, contemporary alternative)

Before considering the Grunt+Puppet path, the Ruby-shop default for deploys was Capistrano. The releases/+/current/+/shared directory layout below is the pattern Capistrano popularised and Fabric's fabistrano ported to Python; the atomic current symlink swap is why a "rollback" was just cap deploy:rollback.

# config/deploy.rb — Capistrano 2.x, circa 2012
set :application, "static-assets"
set :repository,  "git@github.com:example/static-assets.git"
set :scm,         :git
set :branch,      "master"

set :deploy_to,   "/var/www/#{application}"
set :deploy_via,  :remote_cache
set :use_sudo,    false

role :web, "static01.example.com", "static02.example.com"

namespace :deploy do
  desc "Run grunt build on the release directory"
  task :build, :roles => :web do
    run "cd #{release_path} && npm install --production && ./node_modules/.bin/grunt build:prod"
  end

  desc "Reload nginx after symlink flip"
  task :restart, :roles => :web do
    run "sudo /etc/init.d/nginx reload"
  end
end

after  "deploy:update_code", "deploy:build"
after  "deploy:symlink",     "deploy:restart"
# Layout under /var/www/static-assets/:
#   releases/20121118142233/   <- timestamped checkout
#   shared/                    <- shared logs, uploads, node_modules cache
#   current -> releases/20121118142233   <- atomic symlink flip on success

Jenkins post-commit hook (the build trigger)

The 2012 CI default in most enterprise shops was Jenkins (forked from Hudson in 2011). Travis CI existed (launched 2011, free for open source) and CircleCI 1.0 launched in 2011, but for private repos behind a corporate firewall the answer was almost always Jenkins on a self-hosted VM. The standard trigger pattern was a Git post-receive hook hitting the Jenkins buildByToken endpoint:

# .git/hooks/post-receive on the bare repo (or in a Gitolite hook)
#!/bin/sh
while read oldrev newrev refname; do
  branch=$(echo "$refname" | sed 's,refs/heads/,,')
  case "$branch" in
    develop)        job=static-assets-dev ;;
    release/*)      job=static-assets-staging ;;
    master)         job=static-assets-prod ;;
    *) continue ;;
  esac
  curl -s "http://jenkins.internal:8080/job/${job}/build?token=SECRET&cause=push+to+${branch}"
done

The Jenkins job itself was configured through the web UI and serialised to config.xml; the build step shelled out to grunt build:prod and the post-build action either built an RPM via fpm and pushed it to an internal yum repo, or invoked puppet agent --test against the relevant environment.

Contemporary alternatives (2012)

The Grunt + git-flow + Puppet choice was one cell in a much larger matrix. The plausible 2012 alternatives, with the tradeoffs that mattered at the time:

Tool Strength Weakness Why not chosen here
Capistrano (Ruby) Atomic symlink-swap deploys, mature rollback Pulled a Ruby runtime onto every target host Front-end team had no Ruby; Grunt already lived in node_modules
Fabric (Python) Plain Python over SSH, very readable fabfile.py No built-in release-directory model; you wrote it yourself Same shop-language argument as Capistrano, inverted to Python
Chef Ruby DSL, "convergence" model, strong community Required a Chef server or solo bootstrap; Ruby on every node Puppet's declarative manifests were a better fit for static infra
SaltStack ZeroMQ transport, fast at scale, YAML state files Newer (2011); module ecosystem still thin in 2012 Forge's nginx/apache modules were further along than Salt's
CFEngine 3 The original config-mgmt tool; tiny agent footprint Idiosyncratic DSL, steep learning curve Puppet had become the de-facto Linux choice by 2012
Raw shell + cron Zero dependencies, transparent No state, no idempotence, no audit trail The point of the exercise was to eliminate this category
Jenkins-driven deploy Centralised log + permissions, easy approval gates Jenkins job config not version-controlled (pre-Pipeline DSL) Used as the trigger, with Puppet as the actual deployer

The shape of the 2012 decision matrix was: pick a build tool that matched the developer's primary language (Grunt for JS shops, Rake for Ruby, Make/shell for everything else); pick a config-mgmt tool that matched ops' comfort (Puppet or Chef in roughly equal measure in North America, with CFEngine still strong in finance); and glue them together with whatever CI you already ran. The Grunt + Puppet combination in this note is unusual only in pairing a JS-world build tool with a Ruby-world config-mgmt tool — which works fine because the artifact handed off between them is just a directory of files.

Innersource lens

The original note's framing — reduce the communication costs associated with when and what to build and how to deploy — is, in retrospect, almost a one-line definition of what InnerSource Commons would coalesce around three years later. Tim O'Reilly coined the term "InnerSource" in 2000 (in conversations with IBM teams adopting open source practices internally), and the InnerSource Commons community was founded by Danese Cooper at PayPal in 2015 and announced in her OSCON keynote that same year. The InnerSource Patterns book and patterns.innersourcecommons.org are the working catalogue of the practices that emerged.

The 2012 stack as written assumes the deploy pipeline is owned — ops owns the Puppet manifests, the front-end team owns the Gruntfile, release engineering owns the git-flow ritual — and the cross-team contract is the branch name. An innersource-aware version of the same problem would invert the ownership model in three concrete ways:

  1. Puppet manifests as a contributable repo. Instead of ops being the bottleneck for "add a new vhost" or "bump the cache header," the manifests live in a repo with a README, a CONTRIBUTING.md, a PR template, and a small set of trusted committers from ops who review pull requests from any internal team. The InnerSource pattern names for this are "30-Day Warranty" (the contributing team maintains the change for 30 days) and "Trusted Committer."
  2. Grunt config as a vendored internal module. Rather than every front-end team copy-pasting a Gruntfile, the build pipeline ships as an internal npm package (@example/static-build) that exposes a small grunt-config.js API. Teams contribute pipeline improvements upstream via PR; the package is versioned and pinned per consuming project. This is the "InnerSource Library" pattern.
  3. README-driven onboarding for the deploy pipeline itself. The pipeline's own README is the contract: how to add a new environment, how to cut a release, how to roll back, who the trusted committers are. This eliminates the "ask in #ops Slack" step that consumes most of the communication cost the original note set out to reduce.

The deeper alignment between innersource and the trunk-based-development critique of git-flow (Paul Hammant's trunkbaseddevelopment.com is the canonical reference) is that both treat branch proliferation as a proxy for coordination friction. git-flow's five branch types encode five distinct handoff conversations; trunk-based development collapses those to one branch and pushes the coordination into PR review and feature flags — which is precisely the medium an innersource workflow runs on. Read in that frame, the 2012 stack solved the right problem (reduce coordination cost) with the wrong primitive (branches as deploy targets); the innersource + trunk-based answer would have been PRs as deploy proposals, with the same Puppet manifests on the receiving end. Nadia Eghbal's Working in Public (Stripe Press, 2020) documents how that PR-as-proposal pattern scaled from open source into the modern platform-engineering playbook.

Postscript (2026)

Almost every named tool in this 2012 note has been replaced. nvie's git-flow lost the argument to GitHub Flow and trunk-based development, and nvie himself published a note of reflection in March 2020 saying git-flow is the wrong default for web teams. Grunt was eclipsed by npm scripts and then by Gulp; Gulp in turn lost to Webpack, and the current build front-runners are Vite and esbuild — Grunt's last release (1.6.1) is from January 2023 and the project is in maintenance mode. Puppet went through Perforce's 2022 acquisition and a 2024 split between a paid Enterprise edition and the community fork OpenVox (first formal release January 2025); in greenfield work it has largely been displaced by Ansible/Salt for config management and by Kubernetes plus Terraform/Pulumi for provisioning. The piece that survived intact is the underlying pattern — a git branch is the deploy target — now the central abstraction in GitOps via Argo CD and Flux, both CNCF graduated projects.

Author: Jason Walsh

j@wal.sh

Last Updated: 2026-04-19 13:29:49

build: 2026-04-28 22:46 | sha: c759f7e