# Terraform modules vs plain stacks for a five service startup

> Terraform modules vs plain stacks: how a five service startup can pick the simpler setup, move faster, and avoid hidden cost and slow changes.

## Why this choice gets messy fast

A five-service startup gets complicated sooner than it looks. One service needs a database. Another needs a queue. A third runs background jobs. Soon every service has its own secrets, variables, deploy order, permissions, and state.

That is why the module-versus-stack decision gets hard early.

On day one, a shared Terraform module looks clean. You write the pattern once, call it five times, and delete a lot of repeated code. That feels smart right up until the services stop looking alike.

Then the differences pile up. The API needs autoscaling and public access. The worker needs queue permissions but no public endpoint. The admin app needs tighter access rules. The cron service runs on a schedule instead of full time. Preview environments need cheaper defaults.

You can still force all of that through one module, but the cost moves instead of disappearing. The module collects switches, conditionals, and awkward variable names. The repeated code is gone, but the complexity is still there. Now it is harder to see.

That hidden cost hits startups harder than bigger teams. Early on, the shape changes every week. A small infrastructure update should affect one service and move on. With a shared module, one small change can pull every service through the same pattern even when only one of them actually changed.

Debugging slows down too. When a deploy fails, the team no longer starts with the service. They open the module, trace variables, inspect defaults, and work out which branch ran in that one case. It is an annoying way to spend an hour on a problem that plain stacks would show in a few lines.

If your setup is still changing, duplication is often cheaper than abstraction. A startup can live with a few extra lines of Terraform. It usually cannot live with slow changes and murky debugging.

## What plain stacks look like

A plain stack keeps each service in its own Terraform folder with its own state. If your startup runs five services, that might mean five small stacks: web, API, worker, admin, and scheduled jobs. Each stack declares what that service needs and nothing more.

Yes, that repeats some code. Most of the time, that is fine. You may copy provider settings, tags, a few IAM rules, or a standard log group. At this size, clear names matter more than clever reuse. A folder like `services/api` with resources named `api-prod` tells the story quickly.

A simple stack usually contains provider and backend settings, the service resources themselves, a short variable list, and outputs only when another stack truly needs them.

The big advantage is local change. If the worker needs a longer queue timeout, you open the worker stack and change it there. You do not update a shared module, check who imports it, bump versions, and hope nothing else shifts along the way.

That is why plain stacks feel calmer for small teams. The code that builds a service stays close to the service. Reviewers can read one directory and understand what will happen during plan and apply.

New team members benefit too. They do not need to jump between root files, child modules, version pins, and hidden defaults. They can read one stack from top to bottom and build a decent mental model in one sitting.

A five-service startup rarely has enough repetition to justify heavy abstraction. Copying 30 lines across a few stacks is mildly annoying. Chasing module logic across the repo is worse.

Keep the files tidy and the trade-off stays small. Use clear names, short variable lists, and separate state per service. That gives you room to move fast without turning every infrastructure change into a repo-wide project.

## What modules add

Modules do help when two or more services are built in almost the same way. If several services need the same network rules, logging setup, and database pattern, one module can keep that work in one place and reduce drift.

The catch is mental overhead. A plain stack shows the resources right in front of you. A module hides them behind inputs and outputs, which means you often read two places to understand one service. For a small team, that extra step slows reviews, debugging, and routine edits.

Modules work best when the sameness is real. The services differ only by name, size, and a few settings. One team owns the infrastructure code. The pattern will be repeated several times. The module interface stays small and obvious.

Versioning adds another job. Once several services depend on a module, one fix stops being one change. You update the module, tag a version, bump each service, run plans, and roll it out stack by stack. That is safer than surprise updates, but it turns a quick tweak into release work.

A bug inside the module also has a wider blast radius. One bad default, one broken output, or one naming change can break deploys for several services at once. With plain stacks, the damage usually stays local.

That is why modules often look better on paper than they feel in daily work. They are great when they remove repetition without hiding much. They are a bad trade when they turn obvious code into a mini framework that only one person understands.

If three services look similar today but will probably drift apart soon, keep the copy for now. Repetition is annoying. Hidden rules are worse.

## How to decide

Start with the services, not the abstraction. Write down what each service does, who owns it, what network and database access it needs, and how it is likely to scale. A five-service startup usually has more differences than people expect.

After that, look for the parts that stay the same across almost every service. Be strict here. Good candidates are boring pieces that rarely change, like log retention rules, a standard alert, or one small IAM pattern. If a shared part changes every sprint, leave it out.

Keep the first version plain when the differences keep showing up in reviews and deploys. That often happens when naming rules vary by service, networks are not the same, or scaling and security settings differ enough that everyone needs exceptions.

At that stage, plain stacks are easier to read. A new engineer can open one folder and see the real shape of the service without jumping through variables and module outputs.

If you do find one stable repeated block, make one small module for that block only. Do not wrap the whole service. Small modules are easier to test, easier to delete, and less likely to collect strange inputs for edge cases.

Then test that module on one service before you spread it everywhere. Read the plan. Check whether the code is actually shorter and clearer. If you already need exceptions, extra flags, or service-specific workarounds, stop there.

Review the choice after a few releases, not before launch. Real usage tells you what truly repeats, what costs money, and what should stay plain.

## A simple five-service example

Picture a startup with five parts: an API, a background worker, a web app, Postgres, and Redis. At this size, separate Terraform stacks are usually easier to read than one shared module setup.

The layout can stay boring:

- `api`
- `worker`
- `web`
- `postgres`
- `redis`

Each stack owns its own resources, variables, and state. When someone opens the API stack, they see API infrastructure only. They do not need to scan conditional blocks meant for other services.

That matters quickly. The API may need a queue, alarms for request spikes, and autoscaling tied to traffic. The web app may only need a small app service, a CDN setup, and basic monitoring. If both share one large module, the team usually starts adding flags like `enable_queue = true` or `public_endpoint = false`. After a few months, the module stops feeling shared and starts feeling crowded.

The worker creates another split. It scales for a different reason. The API scales when request volume jumps. The worker scales when jobs pile up. That affects instance sizes, scaling policies, alerts, and cost. Keeping the worker plain lets the team change job concurrency without touching web settings or API logic.

Postgres and Redis fit better as their own stacks too. Database changes need more care, different review habits, and slower rollouts. A cache change is usually simpler and easier to replace. Separate stacks make that difference obvious.

Most teams still keep a little shared code. Tags are a common example. Monitoring labels are another. If every stack needs the same owner tag, environment tag, and a few standard labels, extract one tiny module for that and stop there.

For a five-service startup, that is often enough: plain stacks for service-specific infrastructure, plus one or two very small helpers for the pieces that really stay the same.

## When modules actually help

A module earns its keep when several services need the same thing in almost the same shape. That is the point where copy and paste starts costing more than it saves.

If three or more services all need the same VPC pattern, alert setup, or database layout, a module can cut noise quickly. But exceptions are the real test. If every service needs one odd subnet, one custom policy, or one special scaling rule, the module turns into a maze of flags.

Input names matter more than most teams expect. A good module uses words that make sense without opening the source. Names like `service_name`, `instance_size`, or `log_retention_days` are easy to trust. Names like `profile`, `tier`, or `mode` often hide too much.

Change is the next test. A useful module lets you improve one place and roll changes out over time. You might add a safer default, standard tags, or one more alarm without editing every caller on the same day. If one module update forces a full repo sweep, the module is too tight for a small team.

It also helps to test the module in one ordinary service first, not the weird one with special rules. Run plan, apply it, then make one routine change a week later. That small trial usually exposes the awkward input, the missing output, or the hidden assumption.

A quick check works well here:

- Three or more services use the same resource shape with almost no exceptions.
- The inputs can be named in plain language.
- The module can change behind a stable interface.
- One real service has already proven it under normal work.

For a team this small, modules should feel boring. If they save time without making the next change harder, keep them. If they need a long explanation, they are early.

## Mistakes that slow teams down

Small teams usually get stuck because they abstract too early. They build a shared module before the first service settles down, and every later change turns into module surgery.

Early services move a lot. Ports change. Databases split. Queues appear. Naming rules shift. If those guesses live inside a shared module, one service update can ripple through every environment.

Another common mistake is the giant module that does too much. It creates networking, secrets, compute, logging, IAM, and half the naming rules in one place. That looks tidy for about a week. Then one service needs a bigger disk, another needs no public access, and a third needs a different secret path. A small edit turns into a long review.

Costs get buried too. A module can quietly turn on bigger instances, extra load balancers, longer log retention, or a NAT gateway in every environment. The Terraform still looks clean, but the bill tells a different story three months later.

Teams also lose time when they force every service into one template. An API, a worker, an admin app, a scheduled job, and a webhook receiver do not need the same shape. If the template says they do, exceptions start piling up. At that point, the module is harder to read than five plain stacks.

The worst time to fix this is launch week. Refactoring Terraform while traffic, demos, or investor calls are coming up is a bad bet. You can create state drift, break a rollout, or burn half a day chasing a naming change that never needed to happen.

A safer habit is simple: make one service run well, copy the stack for the next few services, extract only the parts that stay the same, and review cost defaults every time you reuse code.

If a shared abstraction saves 30 lines but adds two extra review rounds, it is not helping.

## Quick checks before you refactor

Refactors feel cheap for a day and expensive for six months. Before you move several services into shared modules, test whether the new shape will make daily work easier or just more abstract.

Start with a people check. If a new hire cannot explain how the stack works after ten minutes with the repo open, the setup is already too tangled. A small startup should be easy to read. One service, one stack, one clear place to change it.

Fake reuse causes a lot of pain. Two services can look similar and still need different CPU limits, alert rules, secrets, scaling, or network rules. If the overlap is only a few tags and a naming pattern, copying a small block may be cheaper than building a shared module that every service has to fit.

Ask a harder question too: will this reduce work next month, not just today? A refactor earns its keep when the team will repeat the same change often enough to save real time. If you expect one cleanup pass and little reuse after that, shared abstractions usually add more upkeep than they remove.

Rollback is another good stress test. When one service breaks, can you revert only that service without touching the others? If a shared module forces a coordinated release, you created coupling. That is fine for a few base pieces, like common networking, but risky for app services that change at different speeds.

Do one cost check as well. Shared resources can hide spend. If several services sit on the same database, queue, or observability stack, you need to know what each service actually drives. Otherwise a cheap-looking refactor can make waste harder to spot.

A small team can use this filter:

- A new engineer can read the repo and explain it quickly.
- Each shared block reflects real sameness, not wishful sameness.
- The change saves repeat work in the near term.
- One service can roll back on its own.
- You can name the cost of shared pieces without guessing.

If even two of those fail, keep the stacks plain a bit longer.

## What to do next

Make the next change small. If your team can still read every Terraform file, plan changes without stress, and fix mistakes in one place, keep that shape. A five-service startup does not need shared abstractions just because larger teams use them.

Start with the stack your team can explain in plain words. If one engineer can show a new teammate how networking, compute, secrets, and alerts fit together in 20 minutes, the setup is probably good enough for now. Simple usually beats clever when releases happen every week.

Only pull code into a module after it stays the same through several releases. A good test is boring repetition. If you copied the same block three or four times and each copy changed in the same way, extract it. If every service still needs its own exceptions, leave it in the stack.

It also helps to write down why a module exists before you create it: what problem it solves, which inputs must stay stable, who owns changes, and when the team should stop using it. That note saves time later and makes it easier to retire modules that no longer make sense.

Before a bigger refactor, an outside review can help. Oleg Sotnikov at oleg.is does this kind of work for startups as a Fractional CTO and advisor, and a short review can usually tell whether your Terraform needs a module, a state split, or simply fewer moving parts.

If you do nothing else this week, pick one stack, remove one confusing layer, and write one short rule for when a module is allowed. That is enough to make the next decision easier.
