Why Your Travis Deploy Starts Before Tests Finish—and How Build Stages Fix It
A flat Travis CI matrix runs every job in parallel, so a slow deploy can race ahead of fast unit tests. Build stages group jobs into ordered phases that act as hard gates.
30 Mar 2026, 00:52 UTC

A flat Travis CI build matrix is fast but unordered. If your .travis.yml defines a unit-test job, a lint job, and a deploy job, Travis runs them all at the same time. That means a Docker push to production can begin while unit tests are still executing, and a failing test in one job does nothing to stop a successful deploy in another. The pipeline gives you parallelism, but no ordering guarantee.
Build stages fix this by grouping jobs into named phases that execute in sequence. Jobs inside a stage still run in parallel, but the next stage does not start until every job in the current stage succeeds. You keep fan-out speed for fast checks and gain a hard gate for expensive or side-effectful work.
How build stages change execution order
In Travis CI, a build stage is a named label attached to jobs under jobs.include. When you define stages, Travis sorts jobs by the order in which stages first appear in your YAML. All jobs sharing a stage name run concurrently. If any job in a stage fails, Travis skips every subsequent stage and marks the build as failed.
Without stages, Travis assigns every job to an implicit default stage called test. This is the source of a common surprise: if you add a stage: deploy job but leave your existing matrix jobs without a stage key, those unstaged jobs still belong to test. Because test is the default and typically appears first, your deploy job may run in parallel with your unit tests instead of after them. To get true sequencing, every job that should run early needs an explicit stage name.
A worked example: test, package, deploy
Consider a Node.js project that needs lint and unit tests across two runtime versions, a Docker image build for master, and a deployment for tagged releases. A staged configuration might look like this:
jobs:
include:
- stage: test
node_js: 18
script: npm test
- stage: test
node_js: 20
script: npm test
- stage: test
node_js: 20
script: npm run lint
- stage: package
if: branch = master
script: docker build -t myapp:$TRAVIS_COMMIT .
- stage: deploy
if: tag IS present
script: ./scripts/deploy.sh
Here, the three test jobs run in parallel. If any of them fails, the package and deploy stages are skipped entirely. The package job only runs on the master branch, keeping pull-request builds cheap. The deploy job only runs when a Git tag is present, so routine master pushes build an image but do not publish it.
Note that the if: conditional is evaluated per job, not per stage. If you later add a second job to the package stage, you must repeat the if: branch = master clause on that job as well. There is no stage-level conditional in this syntax.
The cost of sequencing: wall-clock latency
The trade-off is straightforward. A staged pipeline is strictly slower than a fully parallel one because stage two cannot start until stage one fully succeeds. If your test stage takes fifteen minutes, the package stage waits fifteen minutes before its first Docker layer is pulled. For pipelines where raw speed matters more than correctness gating—say, a documentation site with no deploy side effects—staging may add latency without adding safety.
Build stages also do not change the per-job lifecycle. Hooks such as before_install, install, and after_success still run inside every job exactly as they did before. Moving a job into a later stage does not cache its dependencies across stages unless you configure caching explicitly.
Verifying the change before you push
Before committing a staged .travis.yml, validate the syntax locally. Run the following in your repository root, using a terminal where the Travis CLI is installed and authenticated:
travis lint
Expected check: the command returns no errors. Risk: the CLI version installed locally may support a different conditional grammar than the Travis CI server. If travis lint passes but the web UI's YAML validator disagrees, trust the server-side validation and adjust quoting or operators accordingly.
After pushing, confirm behavior with two builds in a scratch repository: one triggered by a pull request and one by a direct push to master. Expected check: the pull-request build runs the test stage and skips package and deploy. The master push runs test and package but skips deploy unless a tag is present. If the deploy job appears on the pull request, verify that its if: clause is attached to the job itself and not inherited from a top-level default.
Start with two stages, not five
If your current .travis.yml runs more than three distinct job types in parallel, resist the urge to design a five-phase pipeline immediately. Audit the file and split it into two stages: fast, broad feedback first—lint and unit tests—and everything else second. Add a branch conditional to the second stage so pull requests stay cheap. Once that gate is reliable, you can carve out a third phase for tagged releases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.