Module 4 · Working on real code

4.0 Getting a project running on your machine

Lesson 4.0 · 12 min read

Someone sends you a repository. It contains a real application, one that thousands of people use. It's all there: the code, the tests, the configuration.

And you cannot run it.

This is the moment that separates people who can build in a fresh directory from people who can work on real software, and it's almost never taught. Everything you've read about React, APIs, databases, and architecture assumed a running system. Now you have to produce one, on your own machine, out of a folder of text files.

Nothing about this is intellectually difficult. It is, however, full of specific knowledge that everyone assumes you have, and the failure modes are opaque if you don't. So we'll go through it properly: what a project actually needs, what dependencies are, how to run the services it talks to, and what to do when it refuses to start.


The code is not the program

Post 5.4 makes this point about containers, and it's worth arriving at from the other direction, because it's the reason this post exists.

The repository contains the code, and the code is the smallest part of what has to be true for the application to run. Underneath it sits a runtime, the program that actually executes your code. Around it sit the dependencies, hundreds of packages your code imports and does not contain. Beside it sit services, a database, perhaps a cache, that the application expects to be able to reach. And threaded through everything is configuration, the values that differ per environment.

Four things, none of which is in the repository. The repository contains the code and a description of the other three. Getting a project running means reconstructing the other three from that description.


The runtime, and why versions matter more than you'd think

Code written in a language needs something to execute it. That's the runtime: a program you install on your machine that reads your code and runs it. Different languages have different runtimes, and they have versions, and the versions are not interchangeable.

A project written against one major version of a runtime may fail on another, sometimes loudly, sometimes with a bizarre error three layers deep in a package you've never heard of. And here is the ordinary situation: you work on two projects that need two different versions. Installing one system-wide breaks the other.

The solution is a version manager: a tool that keeps several versions installed side by side and switches between them per project, usually by reading a small file in the repository declaring which version this project wants. Every major runtime has one, and using it is not an advanced practice. It is the baseline, and skipping it produces a specific kind of misery that people spend years enduring before someone tells them.

When a project won't start and the error is strange, "is this the runtime version the project expects" is one of the first questions, and it is answered in about ten seconds.


Dependencies: the code you didn't write

Open any real project and look at its dependency list. There will be twenty things you chose. Install them and there will be nine hundred packages on your disk.

This is because dependencies have dependencies. You installed a web framework; it depends on a router, which depends on a parser, which depends on three small utilities written by someone in 2014 who has since moved on. The full set is a tree, and your twenty direct choices pull in everything beneath them.

Sit with what that means, because most people never do. Your application is mostly made of code you did not write, did not read, and could not name. It runs with the same permissions as your code. It ships to production inside your container. When one of those packages has a security flaw, your product has a security flaw. When one is quietly taken over by a malicious party, and this has happened repeatedly, your product is compromised.

This isn't an argument against dependencies, which are one of the great accelerations in software. It's an argument for knowing what you have. Adding a package is a real decision. A one-line utility is not worth a new dependency and its tree. And AI, asked to solve a problem, will suggest a package without hesitation, because that's the shape of most answers on the internet.

Two files govern all of this, and understanding the difference is genuinely important.

The first, the manifest, lists what your project wants, usually as ranges: this package, at any version compatible with 4.2. It's a statement of intent, written by humans, and it's the file you edit.

The second, the lockfile, records what you actually got: this exact version of this package, and this exact version of each of its nine hundred descendants, each with a hash proving it's the same bytes. It's generated by the tooling, and you don't edit it, and you absolutely commit it to the repository.

Why? Because without it, "any version compatible with 4.2" means you and your colleague and the production build server each install something slightly different, on different days, and the bug that appears only in production is a version of a package nobody chose. The lockfile makes installation reproducible: everyone gets the identical tree, byte for byte. It's the same instinct as pinning a submodule to a commit, from the Git post, and the same instinct as an immutable container image, from post 5.4. Reproducibility is a recurring obsession in this field, and now you've seen it in three places.

So the install step reads the lockfile if there is one, and reconstructs exactly the tree it describes. This is what "install dependencies" means in the deploy log from post 4.4, and now that stage is no longer mysterious.


Running the services a project needs

Your application talks to a database. The database is not in the repository, and it isn't a library. It's a separate program, running somewhere, listening on a port.

Recall from the deployment post what a port is: a numbered door on a machine, so one computer can run several servers that can be told apart. Your application server on port 3000. Postgres on 5432. Redis on 6379. When your code connects to a database, it opens a connection to a port on some machine, exactly like the client-server journey from post 0.1, because that's what it is.

So to run a project locally, those programs must be running locally. You have two options, and one of them is much better.

You can install them on your machine directly, the way you'd install any application. This works, and it's fine for one project. It becomes unpleasant when a second project wants a different version of the same database, or when you want to start clean, or when you want to remove everything, or when a colleague on a different operating system tries to follow your instructions.

Or you can run them in containers, which is what most people do and why post 5.4 matters more than it initially appeared. A container gives you a Postgres of a specific version, isolated, started with one command, destroyed with another, identical on every machine. A single file in the repository describes which services this project needs and how they're configured, and one command brings all of them up together. Nothing is installed on your machine. Nothing lingers. When you want to start over, you delete the containers and start again in twenty seconds.

That's the practice, and it's why the concept of a container connects directly to your daily life rather than living in an architecture module. Containers didn't just make deployment reproducible. They made your laptop reproducible.


Configuration, migrations, and the last mile

Two more steps before the thing runs, and both were foreshadowed.

Environment variables. From post 4.4: the values that differ per environment, including secrets, kept out of the code. Locally these live in a .env file, which is in .gitignore and never committed. The repository contains .env.example, listing the names with no values. Copy it, fill it in, and you've configured the project. A missing variable here produces a confusing failure at startup or, worse, at the moment some rarely-used code path first runs.

Migrations. Your database is running and it is completely empty. It has no tables. The schema is described in the repository as a series of migration files, and running them creates the tables in order. This is the operation that post 3.1 hinted at when it said changing a schema later is a real thing with real cost; migrations are how schema changes are expressed, versioned, and applied. Locally, running them is one command and it turns an empty database into the one your application expects.

Sometimes there's a seed step too, which fills the empty database with fake data so the application has something to show. A project with good seeds is a joy to work on and a project without them means creating a user, an account, and three orders by hand before you can look at the dashboard.

Then, finally, you start the application, usually in a development mode that watches your files and reloads when you save. And a real project is often several processes at once: a frontend server, a backend server, and a worker consuming the queue from post 5.3. All of them have to be running for the thing to behave as it does in production.


Looking inside the database

One skill that almost nobody teaches, and which changes everything.

When something isn't working, and the screen is blank, and the request returned a 200, there's a question you should be able to answer directly: is the data actually in the database?

You can look. Connect to the running database and run a query. SELECT * FROM orders ORDER BY created_at DESC LIMIT 5; and see what's actually there, from post 3.2. This takes ten seconds and settles an argument that could otherwise consume an hour, because it separates "the data was never saved" from "the data is saved and something else is wrong."

You can do this from a command line client, or a graphical tool, and which you use matters far less than the habit itself. The database is not a black box. It is a program you can talk to, holding rows you can look at. When your application says the order was created, go and see.

This pairs exactly with the network tab from post 1.4. Between them you can see everything: the request that went out, the response that came back, and the row that did or didn't appear. Almost no bug survives all three.


When it doesn't start

It won't, the first time. It never does. Here's the method, and it's the same shape as the production one from post 4.4: find out where it's failing before you try to fix it.

Does it fail during install? Then it's dependencies. Wrong runtime version is the most common cause by far. Something in the tree requires a compiler or a system library you don't have. A private package you can't authenticate to.

Does it fail at startup? Then it's configuration or services. A missing environment variable. It cannot connect to the database, because the database isn't running, or is running on a different port, or the connection string in your .env points somewhere else. Read the error: "connection refused" means nothing is listening there, which almost always means you forgot to start the services.

Does it start but the pages are broken? Then it's data or migrations. The tables don't exist because migrations were never run. The tables exist and are empty because there are no seeds.

Does it start and behave strangely? Then suspect state that shouldn't be there. A stale dependency tree from before you switched branches. A database from a different version of the schema. Cached build output. The unglamorous, genuinely correct move here is to delete the dependency folder, delete the containers, and rebuild from scratch. An enormous fraction of bewildering local problems are old state, and the cure is not clever, it is a clean slate.

And the README, which people skip and which was written by someone who did exactly this. Read it first. If it's wrong, and it often is, fixing it is the single best first pull request you can make to any project, because it is the exact contribution that everyone benefits from and nobody wants to write.


Why this is a foundation, not a chore

It would be easy to read this post as housekeeping before the real material. It isn't, for a specific reason.

Everything before this in the series taught you to understand systems. This post is the one that lets you touch one you didn't build. A repository you can't run is a repository you can only read. You cannot change it, cannot test a hypothesis, cannot see what happens if. Running it locally is the difference between studying software and doing it.

And it is precisely the capability that AI does not give you. An AI can scaffold a fresh project that runs immediately, and this is why so many people can build something and still cannot contribute to anything. The gap between those two people is entirely this post: knowing that a project is code plus a runtime plus a dependency tree plus running services plus configuration plus a migrated database, and knowing which of those is broken when nothing works.

That knowledge does not expire. It will be as true when models write every line of the implementation, because the implementation was never the part that was hard to run.


Do this before the next post

Clone a real open source project you did not write, and get it running. Choose something with a decent README and a modest stack. Give it an hour.

Follow the README exactly, and when it fails, and it will, resist looking for a solution before you've located the failure. Which stage was it? Install, startup, migration, or runtime? Say it out loud before you do anything. You'll find that naming the stage often reveals the fix immediately, and you'll be practising exactly the method from post 4.4 on a problem where nothing is at stake.

Then, once it's running, go and look at its database. Find its migration files and read them: this is the schema you learned to design in Module 3, written down as an ordered set of changes. Connect to the running database and list the tables. Query one. See the seeded rows sitting there, the same rows the application is displaying to you in the browser.

At that moment you are looking at the full stack of a real application, running on your own machine: the frontend rendering, the backend serving, the database holding rows you just read with your own query. You didn't build any of it, and you understand all of it, and you can change it and see what happens.

That's the capability this entire series exists to give you, and this unglamorous post is the one that hands you the keys.