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"]; }
Related notes
- CI/CD Pipelines (2020) — the container-native, YAML-declarative successor to this branch-driven pipeline shape
- Terraform AWS resource patterns — the IaC layer that displaced Puppet manifests for cloud provisioning
- AWS Lambda configuration system — concrete Terraform module patterns for the same provisioning concerns Puppet handled here
- Kubeflow ML pipelines — the same branch-as-deploy-target idea applied to model artifacts and serving environments
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:
- 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."
- 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 smallgrunt-config.jsAPI. Teams contribute pipeline improvements upstream via PR; the package is versioned and pinned per consuming project. This is the "InnerSource Library" pattern. - 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.
