Feb 10, 2026·7 min read

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.

Terraform modules vs plain stacks for a five service startup

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

Decide Before Refactoring
Get a practical review before you turn plain stacks into a heavy module setup.

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

Design For Five Services
Choose structure that matches your API, worker, web, Postgres, and Redis setup.

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

Untangle Your Deploy Flow
Make Terraform plans, applies, and rollbacks easier for a small team.

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.

Frequently Asked Questions

Should a five-service startup start with Terraform modules or plain stacks?

Start with plain stacks. For a startup with five services, separate folders and separate state usually make changes faster, reviews easier, and failures easier to trace.

Use a module later, after you see the same block repeat across several services with almost no exceptions.

When is copy and paste better than a shared module?

Copying a small block wins when services already differ in scaling, access, secrets, or deploy flow. In that case, the extra lines stay easy to read, while a shared module often grows flags and hidden rules.

If your team changes service shape every week, keep the code close to each service for now.

What makes a good first Terraform module?

Pick something small and boring. Tags, one alert pattern, or one simple IAM rule set often works well because those pieces stay stable longer than app service settings.

Do not wrap a whole service as your first module. That is where edge cases pile up fast.

How many similar services do I need before a module makes sense?

A rough rule is three or more services with the same resource shape and very few exceptions. If every service needs its own subnet rule, policy tweak, or scaling setting, you do not have real sameness yet.

The test is simple: if the module needs a long README to explain it, wait.

Why do shared modules make debugging slower?

Debugging slows because the service shape no longer sits in one place. You check the stack, then the module, then the inputs, then the defaults, and only then see what Terraform will build.

With plain stacks, you usually spot the problem right in the service folder.

Should Postgres and Redis live in separate stacks?

Yes, most teams should keep Postgres and Redis in their own stacks. Database and cache changes follow different review habits, carry different risk, and often roll out on a different schedule than app code.

Separate stacks make that clear and reduce the chance that one app change drags data changes along with it.

How do preview environments change this decision?

Preview environments often need smaller defaults and cheaper settings. If one shared module has to support production, staging, and previews for every service, it can fill up with toggles fast.

Plain stacks let you keep preview choices local, which makes cost and behavior easier to see.

What signs tell me a module has become too big?

The warning signs show up fast. You keep adding booleans, vague input names, and service specific exceptions just to make one more caller fit.

Once reviews turn into module archaeology, the module has grown past its useful size.

How should I test a new module without risking the whole repo?

Try it on one ordinary service first. Run plan, apply it, then make one routine change a few days later and see if the code still feels clear.

If you already need workarounds on that first trial, stop and keep the stack plain.

When should I bring in an outside Terraform review?

Ask for outside help before a large refactor, before launch pressure hits, or when your team keeps arguing about structure instead of shipping changes. A short review can tell you whether you need a module, a state split, or just fewer moving parts.

If you want that kind of review, Oleg Sotnikov does this work as a Fractional CTO and startup advisor.

Terraform modules vs plain stacks for a five service startup | Oleg Sotnikov