02. Dependencies
1. The rule: declare your dependencies, and isolate them
The factor is one sentence: explicitly declare and isolate dependencies. It’s one rule with two halves, and you need both. Declare every dependency, completely and exactly, and isolate them so nothing undeclared can leak in at runtime. A twelve-factor app never relies on the implicit existence of system-wide packages. It doesn’t assume the library it needs happens to be sitting on the machine; it states what it needs, and it makes sure that statement is the whole truth when the app runs.
This is exactly where Codebase hands off. Factor 1’s fix for two apps sharing code was to pull the shared code into its own library and have each app depend on it through the dependency manager, like any other package. Factor 2 is what that dependency manager is for. Once shared code becomes a declared dependency, the questions are how you declare it and how you keep the declaration honest, and that’s the whole of this factor.
The two halves are worth keeping separate, because an app fails each one differently. Declaration is a manifest: a file, checked into the repo, that lists what the app needs. Isolation is a runtime guarantee: when the app runs, the declared set is the only set available to it. As the series introduction put it, twelve-factor leans toward operability, and this is operability at its most literal: an app you can install from its own manifest and run anywhere, without a sysadmin having pre-seeded the box.
2. Declare: a manifest, completely and exactly
Declaration means listing all of your dependencies in a manifest that lives in the repo. Every language ecosystem has one:
| Ecosystem | Manifest |
|---|---|
| Node / npm | package.json |
| Python | requirements.txt, pyproject.toml |
| Ruby | Gemfile |
| Go | go.mod |
| Rust | Cargo.toml |
| Java / Maven | pom.xml |
The manifest is the “completely” half: nothing the app imports is left off it. The “exactly” half is harder, and it’s where a plain manifest falls short. A manifest usually records the versions you asked for, often as ranges (^4.17.0, >= 2.1), and it says little about the dependencies of your dependencies. Install from it twice, a month apart, and you can get two different trees, because some transitive package published a new patch in between. Same manifest, different bytes on disk. This split isn’t universal, though. Go collapses it: go.mod is both the manifest and the exact pin (require ... v1.2.3), resolution is deterministic, and since Go 1.17 the file carries the full transitive set, so there are no ranges left to drift. Its companion go.sum isn’t a lockfile at all; it stores integrity hashes that verify what you fetched, not versions that select what you get.
Lockfiles close that gap. A lockfile (package-lock.json, Gemfile.lock, poetry.lock, Cargo.lock) records the exact resolved version of every package in the full transitive tree, direct and indirect, usually with a content hash. It’s generated, not hand-written, and you commit it next to the manifest. One file plays both roles at once: a requirements.txt fully pinned by pip freeze or pip-compile is itself the lock in a plain-pip project that has no separate lockfile. The manifest says what you want; the lockfile says what you got, down to the leaf. Install from the lockfile and you reproduce that tree exactly, on your laptop, in CI and in production. “Completely and exactly” is manifest plus lockfile: the manifest makes the set complete, the lockfile makes it exact.
3. Isolate: make the declared set the set that runs
Isolation is the runtime half: when the app runs, it sees the dependencies it declared and nothing else. A manifest tells you what should be present; isolation guarantees that what’s present is only that. Without it, the surrounding system leaks in. A package installed globally on the machine, for some other app or by the OS itself, sits on the same import path as your declared ones, and your app will happily use it.
Every ecosystem ships a mechanism for this. Python has virtualenv, a per-project directory that shadows the system site-packages so a global install can’t satisfy an import. Ruby has Bundler; run the app under bundle exec and it constrains the load path to exactly the gems the Gemfile.lock resolved. Node leans on node_modules resolution, where packages are looked up in a project-local directory, with no global lookup in the way. Different tools, one job: put a wall between the declared set and whatever else the host happens to have installed.
Neither half is any use alone, and you’ve hit both failure modes. Declaration without isolation is the classic “works on my machine.” You forgot to declare a package, but it’s installed globally on your box, so your app runs fine, satisfied by an ambient copy you never listed. Ship it to a machine that doesn’t have that copy and it breaks and the manifest looks complete the whole time, because the missing dependency was never in it to begin with. Isolation without declaration is the reverse and mostly nonsensical: a sealed environment with nothing to seal, because you never said what belongs inside. So use them as a pair. Declaration tells you the set; isolation makes that set the only truth at runtime.
4. The dependency people forget: system tools
The part everyone forgets is that system tools are dependencies too. It’s easy to picture dependencies as the packages your language’s manager installs and stop there. But if your app shells out to ImageMagick to resize an image, or calls curl to fetch something, or pipes video through ffmpeg, those binaries are dependencies exactly as much as any library, and a twelve-factor app treats them that way.
The trap is assuming they’re simply there. Plenty of machines have ImageMagick and curl installed, so the app works in development and works on the first server, and then it lands on a host that doesn’t have them, or has a version old enough that the flags you rely on don’t exist, and it fails on the first call. The methodology is blunt about this: don’t rely on the implicit existence of any system-wide tool, the same way you don’t rely on a system-wide library. If the app needs it, the app is responsible for it, which means vendoring or declaring it as part of the app’s own environment rather than assuming the host brought it. Holding that line for system binaries is harder than for language packages, which is a big part of what containers are for.
5. Containers: declare and isolate, all the way down
A container image is this factor applied to the entire environment, not just the language packages. The image declares its whole userland: a base image, the packages installed on top, the system tools, the language runtime, your app. And it runs isolated from the host by construction, so nothing on the machine underneath leaks into the app. Declare and isolate, taken to its logical end. The system-tool problem from the last section stops being a problem the moment apt-get install imagemagick goes in the Dockerfile: the tool is now part of the app’s declared environment, vendored into the image, present wherever that image runs. That’s the forgotten part of the factor handled cleanly, in the same place you declare everything else.
But be honest about what a container hands you for free and what it doesn’t. Isolation, you get for almost nothing; that’s the container’s whole nature. Exactness, you don’t. FROM node:latest followed by an unpinned apt-get install is a real declaration and a real isolation, and it is still not reproducible. latest moves. The packages apt-get pulls today aren’t the ones it pulled last month. You can build a perfectly isolated image twice and get two different userlands, which is “completely” without “exactly,” the same gap a bare manifest had, now at the level of the whole environment.
Closing it means doing inside the container what lockfiles do for language packages. Pin the base image by digest (FROM node:20@sha256:...) instead of a moving tag, so the floor never shifts under you. Commit your lockfiles and install from them in the build. Pin system-package versions rather than taking whatever the repo serves that day, though that last step only gets you so far: distro mirrors garbage-collect old versions, so a pinned apt-get install foo=1.2.3 eventually fails to resolve, and full apt reproducibility needs a snapshot mirror (snapshot.debian.org) or vendored .deb files. Do that and the image is much closer to reproducible, isolation and exactness both. This is also where supply-chain tooling picks up the thread: SBOMs (software bills of materials, a manifest of everything in the image) and hash-pinning are the same declare-and-isolate instinct extended to provenance. You don’t need any of it to follow Factor 2; it’s just where the factor keeps going once you care about what’s inside the image down to the hash.
6. The payoff: onboarding in one command
All of this cashes out to a single, concrete thing: a new engineer clones the repo, installs the language runtime and its package manager, runs one deterministic command, and the app runs. npm ci, bundle install, go mod download; one line, and the declared, isolated environment materializes the same way it did on every other machine. Push the idea one step further and even installing the runtime moves into the repo: the devcontainer spec declares the whole development environment as a container, so the clone-and-run promise covers the toolchain too. No wiki page of “first install these six things,” no afternoon lost to a missing library, no version that’s subtly wrong. Onboarding takes minutes because the manifest, the lockfile, and the isolation mechanism together are a complete, exact, executable description of what the app needs.
That same property is what the rest of the series stands on. Build, release, run is where the declared dependencies actually get resolved: the build stage reads the manifest and lockfile and produces a release with the exact tree baked in, which is only possible because Factor 2 made the tree exact in the first place. Dev/prod parity leans on it just as hard, because the biggest source of drift between a developer’s laptop and production is different dependencies, and an app that pins its complete tree has closed that gap before it can open. Declare and isolate isn’t housekeeping. It’s what lets one command rebuild the same app anywhere, and that reproducibility is what Build, release, run and dev/prod parity both depend on.