Organizing Projects and Stacks

Projects and stacks are intentionally flexible so that they can accommodate a diverse needs across a spectrum of team, application, and infrastructure scenarios. This is very much like how Git repos work and, much like Git repos, there are varying approaches to organizing your code within them. That said, there are some clear best practices that, when followed, will ensure Pulumi works seamless for your situation. This article describes some of the most common approaches and when to choose one over another.

Tradeoffs

Everything described below is on a spectrum of tradeoffs. Remember that each project is a collection of code, and that each stack is a unit of deployment. Each stack has its own separate configuration and secrets, role based access controls (RBAC) and policies, and concurrent deployments.

Monolithic

It’s very common to start with a monolithic project/stack structure. In this model, a single project defines the infrastructure and application resources for an entire vertical service.

Each stack typically corresponds to a distinct environment for that service, such as production, staging, and many testing and development instances. There might even be multiple environments within each of these dimensions, such as a production environment in each of the US east coast, west coast, Europe, and Asia.

Most users will start a monolithic structure, for a few good reasons

  • Simplicity. Having a single project and collection of stacks is, quite simply, the easiest thing you could possibly do. Pulumi diffs edits to your application and infrastructure code, and so this approach leaves the hard work of doing incremental deployments, and tracking dependencies, to the Pulumi engine.

  • Versioning. By placing all code in one project, it’s easier to share and version logic within your project. Of course, Pulumi supports package managers, so sharing across projects is also possible, but it entails dealing with packages which means introducing a loosely coupled versioning boundary with distinct update cadences.

  • Agility. All of the above means that using a monolithic approach will almost always lead to the best productivity and therefore agility. For small projects or teams, this is usually the right place to start.

Although a monolithic structure is where most users begin their Pulumi journey, we find that most will ultimately migrate to a finer grained decomposition of projects and stacks.

Micro-Stacks

At the other end of the spectrum is a pattern we call micro-stacks. This is the moral equivalent to microservices, only in project and stack form. In this model, a project is broken into separately managed pieces, often across different dimensions. This approach has several advantages:

  • Independence. Although Pulumi can diff changes and make only those updates mandated by a code edit, certain projects sometimes deploy at radically different cadences and it makes sense to enforce this separation in project structure. For instance, a service that revs every day may not be appropriate to live alongside critical infrastructure that changes infrequently and which demands intense scrutiny whenever it does.

  • Security. In large organizations, it’s important to use RBAC to secure access to individual aspects of your cloud infrastructure and applications. Perhaps you want to ensure your DevOps Architect is the only person who can approve changes to fundamental networking and clustering infrastructure, for example.

  • Complexity and Performance. For many real-world services, there are a multitude of build artifacts. This includes traditional software builds (in Java, .NET, C++, etc), Docker image builds, and serverless function packaging. Putting all of these in one place may increase build times unless a hermetic build system with excellent caching has been used (and, even then, caching across CI/CD machines can be difficult). Breaking apart pieces that can be built independently can increase agility and improve performance, particularly when they evolve at different rates and/or are managed by different teams.

Here are a few, non-exhaustive, examples, of how one might go about splitting up a monolithic project structure:

  • Each micro-service in your architecture might get its own project.

  • Application container images may be rebuilt and published independent of infrastructure projects.

  • Similarly, application concepts like containers and serverless functions may be deployed independently.

  • Core, low-level infrastructure – like networks and cluster orchestrators – may be independent from other infrastructure and applications resources.

  • You may have one or more data tiers that are deployed and independently backed up.

Even with this alternative breakdown, it’s likely your stack structure will mirror that described earlier. For each project, you are apt to have multiple environments such as production, staging, testing, etc. And, indeed, you may have inter-dependencies between your stacks – something that Pulumi supports in a first class manner.

Inter-Stack Dependencies

Let’s imagine you decide to define your cluster infrastructure in one project and consume it from another. Perhaps one project, acmecorp-infra, defines your Kubernetes cluster and another, acmecorp-services, deploys services into it. Let’s further imagine we are doing this across three distinct environments, production, staging, and testing. In that case, we’ll have six distinct stacks, that pair up together:

  • acmecorp-infra-production provides the cluster used by acmecorp-services-production
  • acmecorp-infra-staging provides the cluster used by acmecorp-services-staging
  • acmecorp-infra-testing provides the cluster used by acmecorp-services-testing

The way Pulumi programs communicate information for external consumption is by using stack exports. For instance, our infrastructure stack might export the Kubernetes configuration information needed to deploy into a cluster:

export const kubeConfig = ... a cluster's output property ...;

The challenge here is now our services project needs to ingest this output during deployment so that it can connect to the Kubernetes cluster provisioned in its respective environment.

The Pulumi programming model offers a way to do this with its StackReference resource type. For example:

import * as k8s from "@pulumi/kubernetes";
import * as pulumi from "@pulumi/pulumi";
const env = pulumi.getStack().substring(pulumi.getStack().lastIndexOf("-"));
const infra = pulumi.StackReference(`acmecorp-infra-${env}`);
const provider = new k8s.Provider("k8s", { kubeConfig: infra.getOutput("kubeConfig") });
const service = new k8s.v1.core.Service(..., { provider: provider });

The StackReference constructor takes as input the name of another stack from which to fetch outputs. We need to do a little parsing here to map, say, acmecorp-services-production to acmecorp-infra-production. But once we have that resource, we can fetch the kubeConfig output variable with the getOutput function. From that point onwards, Pulumi understands the inter-stack dependency for scenarios like cascading updates.

Aligning to Git Repos

Because Pulumi is a natural choice for enabling GitOps-style continuous deployment, many users opt to align their project structure to their Git repo structure. Organizations that prefer mono-repos often prefer monolithic project structures, and organizations that prefer fine-grained repos tend to prefer micro-project structures.

This alignment is not a requirement, of course. We have many users who have choose to have multiple projects in a single Git repo – or the reverse, using Git submodules, they might deploy code from multiple Git repos in a single Pulumi project. However, most users find that a close alignment between Git repo structure and Pulumi project structure enables seamless continuous deployment.

In this model, there is a rough correspondence between a Git repo and a Pulumi project, and a Git branch and its associated Pulumi stack. Please read more about how these mapping are maintained here.