Travis CI Interview Questions and Answers
Last updated:
Check out 35 of the most common Travis CI interview questions, then take an AI-powered practice interview
Q1What does .travis.yml control, and what happens when it is missing or invalid?
BasicConfiguration
Answer
The .travis.yml file at the repository root is the entire build definition: language and runtime versions, the OS image, the lifecycle commands, the matrix, caching, deployment providers, notifications and conditions. Travis reads it from the commit being built, not from the default branch, so a change to the config takes effect on the very build that introduces it. That single fact catches people out during migrations, because a broken config on a feature branch fails immediately rather than after merge.
If the file is missing entirely, Travis does not skip the repository. It builds it with defaults, which means language: ruby on the current default Linux image, and the job usually errors out with something like 'Could not locate Gemfile' in a JavaScript project. If the YAML is syntactically invalid, the build errors during the config-parse stage before any script runs and the log shows a config validation message rather than a shell error, which is the quickest way to tell a parse failure from a build failure.
Travis validates the parsed config and surfaces warnings for unknown keys, so a typo like scripts: instead of script: does not error, it silently drops your commands and the job passes with nothing executed. That is one of the most common false-green incidents in Travis pipelines. Run travis lint before pushing, or hit the build config validation shown in the job's 'Config' tab in the web UI, which prints exactly how Travis expanded and normalised your YAML.
# .travis.yml (minimal, explicit, lint-clean)
version: ~> 1.0
language: node_js
node_js:
- '20'
dist: jammy
install:
- npm ci
script:
- npm run lint
- npm test
# Validate before you push:
# travis lint .travis.yml
# travis lint --skip-version-check
Key Points
- Config is read from the commit under build, not the default branch
- A missing file does not skip the repo, it builds with language: ruby defaults
- Unknown keys are warnings, not errors, so typos silently drop commands
- travis lint and the job's Config tab show the normalised expansion
Q2Walk through the Travis CI job lifecycle phases in the order they run.
BasicBuild Lifecycle
Answer
A Travis job runs a fixed sequence of hooks and you can populate any of them. The order is: apt/apt-sources addons and services startup, then before_install, install, before_script, script, then a branch, if script succeeded Travis runs after_success, if it failed it runs after_failure. After that comes before_cache and the cache upload, then before_deploy, deploy, after_deploy (only when a deployment actually runs), and finally after_script, which runs in every case.
Two properties matter far more than the list itself. First, install and script have language-specific defaults, so for language: node_js the default install is npm ci or npm install and the default script is npm test. If you define your own script the default disappears entirely.
Second, after_success, after_failure and after_script cannot change the job result. A non-zero exit in after_script is logged and ignored, which is why people who put coverage uploads or Slack pings there are surprised when a broken upload never turns the build red. If a step must be able to fail the build, it belongs in script. Also note that services (docker, postgresql, redis-server) start before before_install, so a database is usually up by the time your first command runs, but there is no readiness wait, you still need a poll loop for anything slower than the container start.
language: python
python: ['3.12']
services:
- postgresql
before_install:
- sudo apt-get -qq update
install:
- pip install -r requirements.txt -r requirements-dev.txt
before_script:
- until pg_isready -q; do sleep 1; done
- psql -c 'CREATE DATABASE app_test;' -U postgres
script:
- ruff check .
- pytest -q --maxfail=1
after_success:
- bash <(curl -s https://codecov.io/bash)
after_failure:
- tail -n 200 logs/app.log
before_cache:
- rm -rf $HOME/.cache/pip/log
after_script:
- echo "job ${TRAVIS_JOB_NUMBER} finished"
Key Points
- before_install, install, before_script, script, then after_success or after_failure
- before_cache and cache upload run before before_deploy and deploy
- after_script always runs and can never change the job result
- Defining script removes the language default (npm test, rake, pytest and so on)
Q3Why does a failing command in before_install behave differently from a failing command in script?
BasicBuild Lifecycle
Answer
This is the single most-asked Travis CI mechanics question, because it explains the difference between a red build labelled 'errored' and one labelled 'failed'. Commands in before_install, install and before_script are treated as setup. If any of them returns a non-zero exit code, Travis stops the job immediately, marks it errored, and skips straight to after_script.
Nothing after the failing setup command runs. Commands in script are treated as the actual test run. Travis executes every entry in the script list even after one fails, accumulates the exit codes, and marks the job failed if any entry was non-zero.
So a script list of three commands where the first fails still runs the other two, which is usually what you want for lint plus unit plus integration, since you see all three results in one log instead of rerunning three times. Two consequences show up in real pipelines. If you rely on short-circuiting inside script, for example not running a deploy-prep step when tests fail, list-form YAML will not give it to you, you need to join the commands with && into one entry or move them earlier in the lifecycle.
And if you want a setup step whose failure should not kill the job, wrap it with || true, otherwise a flaky apt mirror turns into an errored build with zero test signal. Errored builds are also the ones that most often mask genuine infrastructure problems, so alerting that treats errored and failed identically loses useful information.
before_install:
# Non-zero here ERRORS the job and skips everything below.
- sudo apt-get install -y libpq-dev
# Tolerate a flaky optional step:
- curl -fsSL https://example.com/optional-tool | sh || true
script:
# All three run even if the first one fails; job is marked FAILED.
- npm run lint
- npm run test:unit
- npm run test:integration
# Need short-circuiting? Join into one entry:
- npm run build && npm run smoke-test
Key Points
- Setup phases: first non-zero exit stops the job and marks it errored
- script: every entry runs, exit codes accumulate, job is marked failed
- Use && inside one script entry when you genuinely need short-circuiting
- Guard optional setup with || true so a flaky mirror does not error the job
Q4What is the difference between a build, a job, a stage and a phase in Travis CI?
BasicFundamentals
Answer
These four words are used loosely in conversation and precisely in the Travis data model, and interviewers use the question to check whether you have actually read a build page. A build is everything triggered by one event: a push, a pull request, a cron run or an API call. It has a number like 412 and it owns one or more jobs.
A job is a single VM or LXD container executing the lifecycle once, with its own log, its own environment and its own number like 412.3. Every entry the matrix expands to is a job. A stage is a named group of jobs that runs to completion before the next stage begins, so stages are how you get sequencing across jobs, for example test then deploy.
Jobs inside one stage run in parallel up to your plan's concurrency limit, and if any job in a stage fails, later stages do not start at all. A phase is one hook inside a single job's lifecycle: before_install, install, script and so on. So the containment is build contains stages, stages contain jobs, jobs contain phases.
Getting this right matters practically because caching is per job, secure environment variables are per repository but exposed per job, and TRAVIS_BUILD_ID is shared across jobs while TRAVIS_JOB_ID is not. Anything you need to share between jobs has to go through external storage such as S3 or a package registry, because jobs never see each other's filesystem, not even inside the same stage.
# One build -> two stages -> four jobs total
jobs:
include:
- stage: test # stage 1, three jobs in parallel
name: 'Node 18'
node_js: '18'
- stage: test
name: 'Node 20'
node_js: '20'
- stage: test
name: 'Node 22'
node_js: '22'
- stage: deploy # stage 2, starts only if all of stage 1 passed
name: 'Publish to npm'
node_js: '20'
script: skip
deploy:
provider: npm
api_token: $NPM_TOKEN
on:
tags: true
Key Points
- Build (one event) > stage (sequential group) > job (one VM run) > phase (one hook)
- Jobs in a stage run in parallel; the next stage waits for all of them
- Jobs share no filesystem, use S3 or a registry to pass artifacts
- TRAVIS_BUILD_ID is shared, TRAVIS_JOB_ID is per job
Q5How does the language key change the environment, and what do language: minimal and language: generic give you?
BasicConfiguration
Answer
The language key selects a build image and a set of defaults, not just a compiler. Setting language: node_js provisions nvm plus a pre-installed set of Node versions, sets the default install to npm ci or npm install, the default script to npm test, and enables the node_js version key for matrix expansion. language: python gives you pyenv-managed CPython builds and a virtualenv already active, so pip install writes into the job's own env rather than system site-packages. language: ruby gives rvm and a default script of rake. Each language also seeds different cache shortcuts, cache: bundler for Ruby, cache: pip for Python, cache: npm for Node.
The two odd ones are worth knowing by name. language: minimal boots the smallest image with no runtime preinstalled, which starts fastest and is the right choice when your build is really 'run this shell script' or 'docker build'. language: generic gives a fuller base image with common tooling but still no managed runtime, historically used for Docker-centric and C/C++ builds. Neither of them defines a default install or script, so if you set language: minimal and forget script:, the job does nothing and passes. Picking minimal for Docker-only pipelines is a genuine cost lever, because image boot time is billed the same as test time under the credit model, and a Ruby image you never use still costs you the provisioning minutes.
# Fast Docker-only pipeline: no runtime provisioning at all
language: minimal
dist: jammy
services:
- docker
script:
- docker build -t app:$TRAVIS_COMMIT .
- docker run --rm app:$TRAVIS_COMMIT npm test
---
# Language image with managed runtimes and matrix expansion
language: python
python:
- '3.11'
- '3.12'
cache: pip
script:
- pytest -q
Key Points
- language selects the image plus default install/script plus a version matrix key
- language: minimal is the smallest image, no runtime, no defaults
- language: generic is a fuller base image, still no managed runtime
- Provisioning time is billed, so minimal is a real cost saving for Docker builds
Q6How do you define environment variables in Travis, and when should they live in repository settings instead of .travis.yml?
BasicEnvironment
Answer
There are three places. The env key in .travis.yml defines variables that are visible in the config and in the log, and crucially each entry under env: (or env.jobs:) creates a new matrix job, which is how you fan out across database backends or feature flags. env.global: defines variables shared by every job without expanding the matrix, which is the key people forget, writing a plain env: list when they wanted globals is a classic accidental way to double your build cost. The second place is repository settings in the web UI, where you add a name and value and choose whether it is displayed in the build log and whether it is available to pull request builds.
Settings variables are the right home for anything secret, because they are never committed, they can be rotated without a commit, and Travis masks their values in the log. The third place is encrypted values inside .travis.yml produced by travis encrypt, which are useful when you want the secret to travel with the branch. Precedence runs settings variables first, then global env, then job-level env, with later definitions winning, and TRAVIS_ prefixed names are reserved by the platform. One practical rule: never put a value in .travis.yml that you would have to rotate if the repository went public, and remember that log masking only covers exact string matches, so a token that your build base64-encodes or splits across lines will appear in the log in the clear.
env:
global:
- NODE_ENV=test
- COVERAGE=1
jobs: # each entry becomes a SEPARATE job
- DB=postgres DB_URL=postgres://localhost/app_test
- DB=mysql DB_URL=mysql://root@localhost/app_test
- DB=sqlite DB_URL=sqlite::memory:
script:
- npm run test -- --db "$DB"
# Secrets never go here. Add them in
# Settings > Environment Variables (Display value in build log = OFF)
# or with: travis env set SENTRY_TOKEN xxxxx --private
Key Points
- env.jobs entries expand the matrix; env.global does not
- Repository settings variables are the default home for secrets
- Log masking is exact-string only, encoded or split secrets still leak
- Precedence: settings, then global env, then per-job env
Q7What is travis_retry and where is it safe to use?
BasicReliability
Answer
travis_retry is a shell function that Travis injects into the job environment. Prefixing a command with it runs that command up to three times, with a short pause between attempts, and only reports failure if all three attempts return non-zero. It exists because network-dependent setup is the biggest source of false red builds in hosted CI: apt mirrors time out, npm registry DNS flaps, RubyGems returns a 502, and none of that says anything about your code.
The right use is idempotent, network-bound setup commands: travis_retry npm ci, travis_retry bundle install, travis_retry pip install -r requirements.txt, travis_retry docker pull. The wrong use is your actual test suite. Wrapping travis_retry around npm test converts a genuinely flaky test into a green build and destroys the signal your CI exists to produce, and interviewers ask this specifically to see whether you understand the difference between infrastructure flake and test flake.
Two limits are worth knowing. travis_retry only retries on a non-zero exit code, so a command that hangs is not retried, it hits the 10-minute no-output timeout instead, and you need timeout or travis_wait for that case. And the retry count is fixed at three with no exponential backoff you can configure, so for genuinely rate-limited APIs you are better off writing your own loop with sleep. Also note that travis_retry is a shell function, not a binary, so it does not exist inside a docker run or an ssh session spawned by your job.
install:
- travis_retry npm ci
- travis_retry docker pull ghcr.io/org/base:latest
# Your own backoff when three fast tries are not enough:
before_script:
- |
for i in 1 2 3 4 5; do
curl -sf http://localhost:8080/health && break
echo "attempt $i failed, sleeping ${i}s"
sleep "$i"
done
script:
# NEVER do this: it hides real flakiness
# - travis_retry npm test
- npm test
Key Points
- Retries a command up to three times on non-zero exit
- Correct for network setup: npm ci, bundle install, apt, docker pull
- Never wrap the test suite, it converts flaky tests into false greens
- Does not help with hangs, and is unavailable inside docker run or ssh
Q8How does the Travis build matrix expand, and how do you control the number of jobs it creates?
BasicBuild Matrix
Answer
Travis builds the matrix as a cartesian product of a small set of expansion keys: the language version key (node_js, python, ruby, go, jdk, php and so on), os, arch, dist or osx_image, compiler, and each entry under env.jobs. Three Node versions times two operating systems times two env entries is twelve jobs, and because every job boots its own VM and consumes credits for its full wall-clock time including provisioning, the product grows expensive faster than people expect. You control it three ways. jobs.exclude removes specific combinations from the product, matching on the exact key values, and is the tool for 'everything except Node 18 on macOS'. jobs.include adds one-off jobs that are not part of the product at all, which is how you add a single lint job or a single deploy job without multiplying anything.
And conditions with if: on individual jobs let the same config produce a wide matrix on the default branch and a narrow one on pull requests. A practical detail interviewers probe: jobs.include entries inherit the global settings but not the expansion keys, so an include entry with no node_js gets the first version in your node_js list, not all of them. And exclude matching is literal, if you wrote node_js: 20 as a number in one place and '20' as a string in another, the exclude silently does not match and you keep paying for the job you thought you removed. Always quote version numbers.
language: node_js
node_js: ['18', '20', '22']
os: [linux, osx]
env:
jobs:
- SUITE=unit
- SUITE=e2e
# 3 x 2 x 2 = 12 jobs before pruning
jobs:
exclude:
- os: osx # macOS only needs the current LTS
node_js: '18'
- os: osx
node_js: '22'
- os: osx # and only the unit suite
env: SUITE=e2e
include:
- name: 'Lint' # one extra job, not multiplied by anything
node_js: '20'
os: linux
script: npm run lint
script: npm run test:$SUITE
Key Points
- Cartesian product of language version, os, arch, dist/osx_image, compiler, env.jobs
- jobs.exclude prunes the product, jobs.include adds standalone jobs
- include entries take the FIRST value of each expansion key, not all of them
- exclude matches literally, so quote version numbers consistently
Q9What do TRAVIS_BRANCH, TRAVIS_PULL_REQUEST, TRAVIS_TAG and TRAVIS_EVENT_TYPE contain, and where do people get them wrong?
BasicEnvironment
Answer
TRAVIS_EVENT_TYPE is the cleanest of the four: it is one of push, pull_request, api or cron, and it is the variable you should branch on when behaviour differs by trigger. TRAVIS_PULL_REQUEST is the string 'false' on non-PR builds and the PR number as a string otherwise, so the correct test is a string comparison, and writing if [ -z "$TRAVIS_PULL_REQUEST" ] is wrong because the literal text false is not empty. TRAVIS_BRANCH is the trap.
On a push build it is the branch that was pushed, but on a pull request build it is the branch the PR is targeting, typically main, not the contributor's branch. The head branch is in TRAVIS_PULL_REQUEST_BRANCH, which is empty on push builds. Every 'why did my feature branch deploy to production' incident in Travis traces back to a deploy condition written against TRAVIS_BRANCH on a PR build.
TRAVIS_TAG holds the tag name when the build was triggered by a tag push and is an empty string otherwise, including on branch builds of a commit that happens to be tagged, because Travis distinguishes the ref that triggered the build. Two more that come up constantly: TRAVIS_COMMIT is the SHA under test, but for PR builds Travis builds a merge commit of the PR into the target, so TRAVIS_COMMIT is not the contributor's head SHA, that is TRAVIS_PULL_REQUEST_SHA, which matters when you report status back to an external system or tag a Docker image.
script:
- |
echo "event=$TRAVIS_EVENT_TYPE branch=$TRAVIS_BRANCH pr=$TRAVIS_PULL_REQUEST"
# Correct PR check (string compare, not -z)
if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
echo "PR #$TRAVIS_PULL_REQUEST from $TRAVIS_PULL_REQUEST_BRANCH"
echo "head sha = $TRAVIS_PULL_REQUEST_SHA (not $TRAVIS_COMMIT)"
fi
# Correct release check
if [ -n "$TRAVIS_TAG" ]; then
echo "release build for tag $TRAVIS_TAG"
fi
# WRONG on PR builds: TRAVIS_BRANCH is the TARGET branch
# if [ "$TRAVIS_BRANCH" = "main" ]; then ./deploy.sh; fi
if [ "$TRAVIS_EVENT_TYPE" = "push" ] && [ "$TRAVIS_BRANCH" = "main" ]; then
./deploy.sh
fi
Key Points
- TRAVIS_PULL_REQUEST is the string 'false', never empty, on non-PR builds
- TRAVIS_BRANCH is the TARGET branch on PR builds, head is TRAVIS_PULL_REQUEST_BRANCH
- TRAVIS_TAG is empty unless a tag push triggered the build
- PR builds test a merge commit, so TRAVIS_COMMIT is not the contributor's SHA
Q10Why does one pull request produce two builds, and how do you stop the duplicate?
BasicTriggers
Answer
When a contributor pushes to a branch in the same repository and opens a pull request from it, GitHub sends Travis two events: a push event for the branch and a pull_request event for the PR. Travis honours both, so you get a push build of the branch head and a pull request build of the merge commit, doubling credit consumption and cluttering the commit status list. Forks do not have this problem, since the push event belongs to the fork, which is why the duplicate only appears for teams using branches in the main repository.
There are three fixes and they suit different workflows. The blunt one is the repository settings toggles, 'Build pushed branches' and 'Build pushed pull requests', turning off the former means branch pushes only build once a PR exists, which is fine for teams that always work through PRs but leaves branch-only work unbuilt. The precise one is a condition at the top of .travis.yml that skips push builds for any branch other than your long-lived ones, letting pull request builds cover feature work.
The third is branches.only, which is coarser because it also filters PR builds against their target branch. Prefer the condition, because it keeps everything in the repository where reviewers can see it and it survives a repository being recreated, whereas UI toggles are invisible in code review and get lost during migrations. Interviewers like this question because the answer shows whether you have ever looked at a Travis bill.
# Build every pull request, but only push builds on protected branches.
if: type != push OR branch IN (main, develop) OR tag IS present
language: node_js
node_js: ['20']
script: npm test
# Coarser alternative (also filters PR builds by TARGET branch):
# branches:
# only:
# - main
# - develop
# - /^v\d+\.\d+\.\d+$/
Key Points
- GitHub fires push and pull_request separately for same-repo branches
- Top-level if: is the version-controlled fix, UI toggles are invisible in review
- branches.only filters PR builds by their target branch, which is rarely what you want
- Forked PRs never duplicate, only same-repo branches do
Q11How does caching work in Travis, and when do you have to clear a cache manually?
BasicCaching
Answer
Travis caching is a tar archive of directories you nominate, uploaded to Travis-managed object storage after the script phase (with before_cache as your last chance to prune it) and restored at the start of a matching job. The cache key is derived from the repository, the branch, the operating system, the language version and the job's environment variables, so every matrix job keeps its own cache and a Node 20 job never restores a Node 22 cache. Branch caches fall back to the default branch's cache when a branch has none yet, which is why the first build of a new branch is usually fast.
You enable it with language shortcuts (cache: npm, cache: bundler, cache: pip, cache: cargo) or an explicit cache.directories list, and cache: false turns it off. The critical property is that Travis never invalidates a cache for you. There is no content hash of your lockfile in the key, unlike newer CI systems, so a cache poisoned by a half-installed dependency or a stale native extension persists across every build on that branch until a human deletes it.
That is the mechanism behind 'it fails on CI but passes locally, and it started for no reason'. Clear it with travis cache --delete --branch main from the CLI, or via 'Caches' in the repository settings. Also cache directories, never a package manager's global state that includes a lock or a daemon socket, and always prune logs and temp files in before_cache, since upload and download of a bloated cache can cost more time than the install it replaces.
cache:
npm: true
directories:
- $HOME/.cache/pip
- $HOME/.gradle/caches
- node_modules
before_cache:
# Prune junk so the tarball stays small
- rm -rf $HOME/.gradle/caches/*/plugin-resolution/
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- find $HOME/.cache/pip -name '*.log' -delete
# Manual invalidation (there is NO automatic lockfile-hash key):
# travis cache --delete --branch main
# travis cache --delete --repo org/app --branch feature/x
Key Points
- Key = repo + branch + os + language version + env, so caches are per matrix job
- New branches inherit the default branch cache as a fallback
- No lockfile hashing, Travis never invalidates a cache automatically
- Prune in before_cache, and clear with travis cache --delete when builds go weird
Q12Which Travis CLI commands do you actually use day to day?
BasicTooling
Answer
The travis gem is still the fastest way to work with Travis outside the web UI, and interviewers use this question to separate people who clicked buttons from people who operated the pipeline. Authentication comes first: travis login --pro (or --com on newer builds of the CLI) exchanges a GitHub token for a Travis token, and travis token prints it for use in scripts. travis lint validates .travis.yml locally and catches the unknown-key typos that otherwise produce silent no-op builds. travis encrypt turns a value into a secure blob and, with --add, writes it into the right place in your config. travis encrypt-file does the same for a whole file. travis env list, travis env set NAME value --private and travis env unset manage repository settings variables without opening a browser, which is what you want in an onboarding or rotation script. travis cache --delete clears a poisoned cache. travis logs, travis show and travis history inspect recent builds from the terminal, travis restart and travis cancel act on them, and travis monitor streams events live. travis setup <provider> scaffolds a deploy block for common providers with the token already encrypted. Two operational notes: nearly every command takes --repo owner/name so you can run it from outside the working copy, and the CLI defaults to the wrong endpoint often enough that pinning --com or setting the TRAVIS_ENDPOINT variable in your shell profile saves a lot of confusion when a command reports the repository does not exist.
# Auth and sanity
travis login --com
travis lint .travis.yml
travis whoami
# Secrets and settings variables
travis encrypt SLACK_TOKEN=xoxb-123 --add notifications.slack
travis env set AWS_SECRET_ACCESS_KEY "$KEY" --private --repo org/app
travis env list --repo org/app
# Operating builds
travis history --limit 10 --repo org/app
travis logs 412.3 --repo org/app
travis restart 412 --repo org/app
travis cancel 412.5 --repo org/app
travis cache --delete --branch main --repo org/app
Key Points
- travis lint before every config change, it catches silent no-op typos
- travis env set --private manages secrets without the web UI
- travis logs / restart / cancel / monitor for day-to-day operations
- Pin the endpoint with --com or TRAVIS_ENDPOINT to avoid 'repo not found'
Q13How do branches.only, branches.except and safelisted tags interact?
BasicTriggers
Answer
The branches key filters which refs Travis will build at all, before any job or matrix logic runs. branches.only takes a list of exact names or regular expressions written between slashes, and it is a safelist, anything not listed is not built. branches.except is the inverse blocklist. You cannot use both meaningfully, if both are present only wins and except is ignored, which is a common source of confusion in configs inherited from another team. Two behaviours surprise people.
First, tags are treated as refs here, so the moment you add branches.only: [main], tag pushes stop building unless you also list a regex that matches your tag names, which is how release pipelines quietly stop publishing after someone tidies up the config. The usual fix is to add a pattern like /^v\d+\.\d+\.\d+$/ alongside your branch names. Second, on pull request builds the filter is applied to the target branch, not the source, so branches.only: [main] still builds a PR from any feature branch into main, which is usually the behaviour you want but is not what the key name suggests. In modern configs most teams prefer the if: condition language over branches, because conditions can combine branch, type, tag, fork status, environment variables and even the commit message in one expression, and they can be applied per job or per stage rather than globally. branches remains useful as a cheap global gate that stops a build before it consumes any credits.
branches:
only:
- main
- develop
- /^release\/\d+\.\d+$/
- /^v\d+\.\d+\.\d+$/ # WITHOUT this, tag builds stop happening
# Equivalent, more expressive, and works per job or per stage:
# if: >-
# branch IN (main, develop)
# OR branch =~ /^release\//
# OR tag IS present
Key Points
- only is a safelist, except is a blocklist, only wins if both are present
- Tags are refs too, adding branches.only silently disables tag builds
- On PR builds the filter applies to the target branch
- if: conditions are strictly more expressive and work per job or stage
Q14What timeouts does Travis enforce, and how do you handle a legitimately long-running command?
BasicBuild Lifecycle
Answer
Travis enforces two independent timeouts and confusing them wastes hours. The first is the no-output timeout: if a job produces no log output for 10 minutes, Travis assumes it has hung and terminates it, and the log ends with a message about no output being received. The second is the hard job timeout, 50 minutes for Linux jobs on the standard hosted plans, with longer limits on some plan tiers and on Travis CI Enterprise, after which the job is killed regardless of how much output it produced.
The distinction matters because the fixes are different. A silent-but-working command, a long compile, an integration suite that buffers output, a large upload, needs travis_wait, a helper that runs the command in the background while printing a heartbeat to the log. travis_wait 30 ./long_task.sh extends the silence tolerance to 30 minutes but cannot exceed the hard job limit, and it captures the command's output to a file that it dumps at the end rather than streaming it, which makes debugging harder, so unbuffering your tool's output is usually the better fix. A job that genuinely needs more than 50 minutes of wall clock cannot be rescued by travis_wait at all, it has to be split across matrix jobs or stages, or made faster with caching and parallelism. Interviewers follow up by asking how you would find out which phase is slow, and the answer is the per-phase timing Travis prints in the log fold headers plus the job's total duration on the build page.
script:
# Silent for 25 minutes: heartbeat keeps the 10-min watchdog happy
- travis_wait 30 ./gradlew --no-daemon build
# Better than travis_wait: make the tool talk
- stdbuf -oL -eL pytest -q --durations=20
# Hard-cap a command yourself so you fail fast instead of at 50 min
- timeout 900 ./integration-suite.sh || { echo 'suite exceeded 15m'; exit 1; }
# Over 50 minutes total? Split it, do not extend it:
# env:
# jobs:
# - SHARD=1of3
# - SHARD=2of3
# - SHARD=3of3
Key Points
- 10 minutes with no log output kills the job; 50 minutes total is the hard cap on standard Linux plans
- travis_wait raises only the silence tolerance, never the hard cap
- travis_wait buffers output to a file, so prefer unbuffered tooling
- A genuinely 50-minute job must be sharded across matrix jobs or stages
Q15How do secure environment variables work in Travis, and why are they unavailable on pull requests from forks?
IntermediateSecurity
Answer
Travis generates an RSA key pair per repository. travis encrypt VALUE encrypts your plaintext with the repository's public key and emits a base64 blob you place under a secure: key in .travis.yml; the Travis build environment holds the private key and decrypts it into the job's environment just before before_install. Repository settings variables work differently, they are stored encrypted at rest in Travis's database and injected the same way, but they never appear in the repository. Both categories share one hard rule: they are not exposed to builds of pull requests originating from forks.
The reason is that a fork PR runs attacker-controlled code, and a malicious .travis.yml in the fork could simply echo the decrypted secret, or pipe it to a remote host, in a build the maintainer did not review. Since Travis builds the config from the PR head, there is no review gate before execution, so the platform removes the secrets instead. Practically this means fork PRs cannot push Docker images, upload coverage with a token, deploy previews or hit any authenticated API, and any script that assumes a token exists must degrade rather than error.
Check TRAVIS_SECURE_ENV_VARS, which is the string true or false, or test the variable directly. Repository settings variables have an 'Available to pull requests' toggle for values you accept exposing, and it should stay off for anything with write scope. Interviewers usually follow up with the 2021 disclosure in which secure variables were exposed in some public-repository pull request builds, and the right takeaway is that CI secrets should be short-lived, single-purpose and scoped so tightly that leaking one is an incident you can contain rather than a breach.
# Encrypt a value into .travis.yml (repo-specific RSA public key)
# travis encrypt CODECOV_TOKEN=abc123 --add env.global --repo org/app
env:
global:
- secure: "Zx3k...base64-blob...=="
after_success:
- |
if [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then
bash <(curl -s https://codecov.io/bash) -t "$CODECOV_TOKEN"
else
echo 'fork PR: no secrets available, skipping coverage upload'
fi
# Prefer settings variables you can rotate without a commit:
# travis env set CODECOV_TOKEN abc123 --private --repo org/app
Key Points
- Per-repository RSA key pair, decrypted into the env before before_install
- Never exposed to fork PR builds, because the fork controls the config
- Gate on TRAVIS_SECURE_ENV_VARS and degrade instead of failing
- Scope CI credentials so tightly that a leak is containable
Q16When would you use travis encrypt-file instead of an encrypted environment variable?
IntermediateSecurity
Answer
Encrypted environment variables have a practical size limit, roughly a few hundred bytes per secure blob, because RSA encryption is bounded by the key size. That is fine for a token and useless for a service-account JSON, an Android keystore, an Apple provisioning profile, a private SSH key or a kubeconfig. travis encrypt-file handles those. It generates a random AES-256-CBC key and IV, encrypts your file with openssl into a .enc file you commit, and then stores the AES key and IV as two secure environment variables named after a hash of the file.
It also prints (and with --add writes) the openssl command that reverses the process in the build. The result is that the ciphertext lives in the repository and only the short symmetric key is a Travis secret. Three operational cautions.
First, the generated decrypt command is tied to the variable names it created, so if you regenerate the file you must re-run encrypt-file and update the command, and if you copy the config to another repository the variables do not exist there. Second, the decrypted file lands in the workspace, so add it to .gitignore and delete it in before_cache, otherwise you will cheerfully upload a private key into a shared cache tarball. Third, the same fork-PR rule applies, the key variables are absent, so decryption fails there and your script must handle it. In 2026 most teams have moved multi-line secrets to a real secret manager and fetch them at build time with a narrowly scoped credential, which keeps rotation out of the repository entirely.
# One-time, locally:
# tar cf secrets.tar service-account.json id_rsa_deploy
# travis encrypt-file secrets.tar --add --repo org/app
# git add secrets.tar.enc .travis.yml && rm secrets.tar
before_install:
- |
if [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then
openssl aes-256-cbc \
-K $encrypted_9f2a1b3c4d5e_key \
-iv $encrypted_9f2a1b3c4d5e_iv \
-in secrets.tar.enc -out secrets.tar -d
tar xf secrets.tar
chmod 600 id_rsa_deploy
fi
before_cache:
- rm -f secrets.tar service-account.json id_rsa_deploy
Key Points
- Use it for anything larger than a token: keystores, kubeconfigs, SSH keys
- AES-256-CBC key and IV are stored as two generated secure variables
- Delete decrypted files in before_cache or they land in the cache tarball
- Regenerating the file changes the variable names, update the config too
Q17How do build stages work, and how do you model a test-then-build-then-deploy pipeline with them?
IntermediateBuild Stages
Answer
Stages give Travis the one thing the plain matrix cannot express: ordering between jobs. You declare them by adding a stage: name to entries under jobs.include. Jobs sharing a stage name run in parallel, the next stage does not start until every job in the current stage has succeeded, and stages appear in the order they first occur in the file unless you declare an explicit stages: list.
If any job in a stage fails, all later stages are skipped and the build is marked failed, which is precisely the semantics you want for 'do not deploy if tests failed'. Jobs generated by matrix expansion, rather than by include, are placed in an implicit first stage named test, so a config that has both a node_js list and jobs.include entries with stage: deploy already behaves correctly without further work. Two constraints shape real pipelines.
Stages do not share a filesystem, so a build artifact produced in stage two must be published somewhere (S3, GitHub Releases, a package registry, a container registry) for stage three to consume, and candidates who describe passing artifacts 'through the stage' have not used it. And you cannot make a stage conditional on another stage's output beyond pass or fail, there is no data flow, only ordering. Use script: skip on a deploy-only job so Travis does not run the language default test command before deploying, and put the deploy conditions in the deploy.on block rather than in shell, so the build page shows the job as skipped rather than silently green.
stages:
- test
- name: build
if: branch = main OR tag IS present
- name: deploy
if: tag IS present
jobs:
include:
- stage: test
node_js: '20'
script: npm run test:unit
- stage: test
node_js: '20'
script: npm run test:integration
- stage: build
node_js: '20'
script: npm run build
# artifacts must leave the job to be visible later
after_success: aws s3 cp dist s3://ci-artifacts/$TRAVIS_BUILD_ID --recursive
- stage: deploy
script: skip
before_deploy: aws s3 cp s3://ci-artifacts/$TRAVIS_BUILD_ID dist --recursive
deploy:
provider: s3
bucket: prod-assets
local_dir: dist
on:
tags: true
Key Points
- Jobs in a stage run in parallel, stages run strictly in sequence
- Matrix-expanded jobs land in an implicit first stage called test
- No shared filesystem between stages, publish artifacts to S3 or a registry
- script: skip stops the language default test command on deploy-only jobs
Q18Explain jobs.include, jobs.exclude, allow_failures and fast_finish precisely.
IntermediateBuild Matrix
Answer
These four keys (historically under matrix:, now under jobs:, both still parsed) control the shape and the failure semantics of your matrix. jobs.include appends jobs that are not part of the cartesian product; each entry inherits global config and fills unspecified expansion keys with the first value from their list, so an include entry without node_js gets your first Node version. jobs.exclude removes product entries by exact key match; a mismatch in type or quoting means the exclusion silently does nothing. jobs.allow_failures lists jobs whose failure does not fail the build; they still run, still consume credits, and still show red on the build page, but the overall build stays green. It is the correct way to run a nightly or experimental configuration, for example the next major runtime version or a platform you support on a best-effort basis, without blocking merges. The classic mistake is using allow_failures on a genuinely important job to unblock a release, which quietly removes it from your quality gate for the next two years. jobs.fast_finish changes when the build result is reported: with it enabled, Travis marks the build as finished as soon as all jobs that can affect the result have completed, without waiting for the allowed-failure jobs to finish.
It does not cancel those jobs, they keep running and keep billing, it only stops them from delaying your merge. fast_finish without allow_failures does nothing useful. A follow-up interviewers like: how do you notice that an allowed-failure job has been broken for months? The answer is that you need out-of-band alerting, because the build status will never tell you.
language: go
go: ['1.22', '1.23']
os: [linux]
jobs:
include:
- name: 'race detector'
go: '1.23'
script: go test -race ./...
- name: 'go tip (experimental)'
go: master
script: go test ./...
exclude:
- go: '1.22'
os: linux
env: SLOW=1
allow_failures:
- go: master # must match the job's keys, not its name
fast_finish: true # report the result without waiting for go tip
script: go test ./...
Key Points
- include adds jobs outside the product and inherits the first value of unset keys
- exclude matches literally, quoting mismatches make it a no-op
- allow_failures still runs and still bills, it only stops the job failing the build
- fast_finish reports early but does not cancel the allowed-failure jobs
Q19How does the if: condition language work, and at which levels can you apply it?
IntermediateConditions
Answer
Travis has a small expression language (conditions v1) usable at the top level of .travis.yml, on an individual job inside jobs.include, and on a stage inside the stages list. The available operands include branch, tag, type (push, pull_request, api, cron), repo, fork, os, env(NAME), sender, head_branch and commit_message. Operators are =, !=, =~ and !~ for regular expressions, IN and NOT IN for lists, IS present and IS blank for existence, and AND, OR, NOT with parentheses for grouping.
String literals with spaces need quoting, and a regex is written between slashes. Applying a condition at the top level decides whether the build happens at all, so a filtered-out event produces no build record and consumes no credits. Applying it to a job means the build runs but that job is not created, which is the standard way to keep deployment jobs out of pull request builds.
Applying it to a stage skips every job in that stage. The evaluation happens on Travis's side before the VM boots, so conditions cannot depend on anything computed during the build, only on the trigger metadata and on env variables defined in the config (not on settings variables set in the UI, which is a frequent surprise). Two idioms worth memorising: if: type = push AND branch = main for deployment jobs, and if: commit_message !~ /\[skip ci\]/ if you want that convention on a platform where the built-in skip token is not enough. Multi-line conditions need a YAML block scalar, and travis lint will tell you when your expression fails to parse.
# Top level: no build at all for these events
if: >-
type IN (push, pull_request, cron, api)
AND commit_message !~ /\[ci skip\]/
stages:
- test
- name: publish
if: tag IS present AND repo = org/app AND fork = false
jobs:
include:
- stage: test
name: 'nightly full suite'
if: type = cron # only on the scheduled run
script: npm run test:full
- stage: test
name: 'quick suite'
if: type != cron
script: npm run test:quick
- stage: publish
if: env(PUBLISH) = true
script: npm publish
Key Points
- Applies at top level (no build), per job (no job), or per stage (no stage)
- Operands: branch, tag, type, repo, fork, os, env(NAME), sender, commit_message
- Evaluated before the VM boots, so it cannot see anything your build computes
- env() reads config-declared variables, not UI settings variables
Q20How does the deploy: key work, and what does the on: block control?
IntermediateDeployment
Answer
Deployment in Travis is handled by dpl, a Ruby tool with a provider per target: s3, npm, pages, heroku, cloudfoundry, releases (GitHub Releases), pypi, rubygems, gcs, elasticbeanstalk, script (run your own command) and many more. You declare it under deploy: with the provider name, the provider's credential keys and its options, and Travis runs it in the deploy phase after before_deploy and the cache upload. The on: block decides whether the deployment actually executes for this job: branch (default is the repository's default branch, which is why a deploy silently does nothing on a feature branch), tags: true to require a tag build, repo to prevent forks from deploying, condition for an arbitrary shell test, and all_branches: true to lift the branch restriction.
Crucially, on: is evaluated per job, so in a matrix of six jobs the deploy block would run six times unless you also constrain it, which is how teams end up publishing the same npm package six times and hitting a version conflict on five of them. The fix is to put deployment in its own stage or its own include entry, not in the shared config. Two more practical points. dpl v2 wants credentials as encrypted variables, never plaintext, and travis setup <provider> will encrypt them for you in place. And provider: script with script: ./deploy.sh is the escape hatch when the built-in provider is missing an option, but remember that a script provider deployment still respects on:, and that a non-zero exit from it fails the build, unlike a stray command in after_success.
jobs:
include:
- stage: release
name: 'GitHub Release + npm'
node_js: '20'
script: npm run build
deploy:
- provider: releases
token: $GITHUB_TOKEN
file_glob: true
file: dist/*.tgz
cleanup: false
on:
tags: true
repo: org/app
- provider: npm
email: ci@org.com
api_token: $NPM_TOKEN
on:
tags: true
condition: $TRAVIS_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$
- provider: script
script: ./scripts/notify-release.sh
on:
tags: true
Key Points
- dpl providers cover S3, npm, Pages, Heroku, GitHub Releases, PyPI and more
- on: defaults to the default branch, so feature-branch deploys silently no-op
- deploy runs per job, isolate it in a stage or you will deploy N times
- cleanup: false preserves build artifacts that dpl would otherwise git-clean away
Q21How do you configure multi-OS and multi-architecture builds with os, dist, arch and osx_image?
IntermediateBuild Environments
Answer
Travis supports os: linux, osx, windows and freebsd, and on Linux it also supports arch: amd64, arm64, ppc64le and s390x. That last group is the reason a number of open-source projects and enterprise teams stayed on Travis long after GitHub Actions arrived, because IBM Z and POWER runners are not something you get elsewhere without self-hosting. Each os has its own image selector: dist chooses the Ubuntu release on Linux (xenial, bionic, focal, jammy and newer releases as they are added), osx_image chooses the macOS plus Xcode combination, and Windows offers a much smaller set of images.
All of these are matrix expansion keys, so listing three dists and two arches multiplies your job count. Two knobs are easy to miss. virt: chooses between the lighter LXD container and a full VM, and you need virt: vm for anything requiring nested virtualisation or a real kernel module, while lxd boots faster and is the default on newer images. group: edge opts a job into a pre-release image, which is how you test an upcoming Ubuntu before it becomes the default. Cost matters here: macOS and Windows jobs consume credits at a higher multiplier than Linux amd64, so a naive three-OS matrix on every commit is the fastest way to burn a monthly allowance. The usual production shape is Linux amd64 on every push, other operating systems and architectures gated behind if: type = cron or if: branch = main so they run daily and on the default branch rather than on every pull request.
language: c
compiler: [gcc, clang]
jobs:
include:
- os: linux
dist: jammy
arch: amd64
- os: linux
dist: jammy
arch: arm64
virt: vm
- os: linux
arch: s390x # IBM Z, rarely available elsewhere
if: type = cron
- os: osx
osx_image: xcode15.4
if: branch = main OR tag IS present
- os: windows
if: type = cron
script:
- ./configure && make && make check
Key Points
- arch supports amd64, arm64, ppc64le and s390x on Linux
- dist selects the Ubuntu image, osx_image the macOS plus Xcode pair
- virt: vm for nested virtualisation, virt: lxd for faster boots
- macOS and Windows cost more credits per minute, gate them behind cron or main
Q22How do you build and push Docker images from a Travis job?
IntermediateDocker
Answer
Add services: - docker and the daemon is started before before_install. From there it is ordinary Docker, with a few CI-specific concerns. Log in with --password-stdin reading from a secure environment variable, never as a command-line argument, because arguments appear in the process list and, if the log is verbose, in the log itself.
Tag images with something reproducible, TRAVIS_COMMIT is better than latest and TRAVIS_TAG is right for releases; using only latest makes it impossible to tell which build produced a running container. Layer caching does not survive between jobs because each job is a fresh VM, so either accept a cold build, pull the previous image and pass --cache-from, or use buildx with a registry-backed cache exporter, which is the cleaner option on recent images. Guard every push with a check that this is not a fork pull request, because the registry credentials will not exist and the failure message ('unauthorized') is much less obvious than the cause.
On the resource side, Travis Linux jobs have limited disk, so an unpruned build of a multi-stage image with several large bases can fill the disk and produce a confusing 'no space left on device' during the layer write, and docker system prune -af between steps is a legitimate fix. Finally, remember the 50-minute cap: a full multi-arch buildx run with QEMU emulation for arm64 is often slower than that, so build native arm64 on an arch: arm64 job and combine the two with a manifest instead of emulating.
language: minimal
dist: jammy
services:
- docker
env:
global:
- IMAGE=ghcr.io/org/app
before_script:
- echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
script:
- docker pull $IMAGE:cache || true
- docker build --cache-from $IMAGE:cache
-t $IMAGE:$TRAVIS_COMMIT -t $IMAGE:cache .
- docker run --rm $IMAGE:$TRAVIS_COMMIT npm test
after_success:
- |
if [ "$TRAVIS_PULL_REQUEST" = "false" ] && [ "$TRAVIS_BRANCH" = "main" ]; then
docker push $IMAGE:$TRAVIS_COMMIT
docker push $IMAGE:cache
fi
Key Points
- services: docker starts the daemon before before_install
- Always docker login with --password-stdin, never as an argument
- No layer cache between jobs, use --cache-from or a buildx registry cache
- Build native arm64 on an arch: arm64 job rather than emulating with QEMU
Q23How do you schedule nightly builds in Travis, and how do you trigger a build from outside GitHub?
IntermediateTriggers
Answer
Scheduled builds live in the web UI only, under Settings > Cron Jobs, where you choose a branch, an interval of daily, weekly or monthly, and whether the cron always runs or skips when a build already happened in the last 24 hours. That UI-only design is the biggest complaint about Travis crons: they are not expressible in .travis.yml, so they are invisible in code review, they do not survive a repository being recreated during a migration, and renaming the branch silently orphans them. The cron build uses the .travis.yml at the head of that branch and the job sees TRAVIS_EVENT_TYPE=cron, which is what you gate on with if: type = cron so the expensive matrix, the dependency audit or the s390x job runs once a night rather than on every push.
For triggers that come from outside GitHub, use API v3's requests endpoint: POST to /repo/{slug}/requests with the Travis-API-Version: 3 header and a token from travis token, remembering that the slug is URL-encoded, so org/app becomes org%2Fapp. The body carries branch, an optional message that shows on the build page, and a config object merged into the repository config using merge_mode of merge, deep_merge, deep_merge_append or deep_merge_prepend. That merge is what makes the API genuinely useful: a release script or an internal dashboard can start a build with extra variables or a different script without committing anything. Requests are rate limited per repository and land in the Requests tab, where a rejected one shows its reason, which is exactly where you look when curl returns success and no build appears.
# Cron is UI-only (Settings > Cron Jobs). React to it in config:
jobs:
include:
- name: 'nightly dependency audit'
if: type = cron
script:
- npm audit --audit-level=high
- npx license-checker --failOn GPL-3.0
# Trigger a build, with a one-off config overlay, from anywhere:
# curl -s -X POST \
# -H 'Travis-API-Version: 3' \
# -H 'Content-Type: application/json' \
# -H "Authorization: token $TRAVIS_TOKEN" \
# -d '{"request":{
# "branch":"main",
# "message":"smoke run from on-call dashboard",
# "config":{
# "merge_mode":"deep_merge",
# "env":{"global":["SMOKE=1"]}
# }}}' \
# https://api.travis-ci.com/repo/org%2Fapp/requests
Key Points
- Cron schedules are UI-only, invisible in code review and lost on repo recreation
- Cron jobs set TRAVIS_EVENT_TYPE=cron, gate expensive work with if: type = cron
- API v3 POST /repo/{slug}/requests needs the slug URL-encoded and Travis-API-Version: 3
- config plus merge_mode lets an API trigger override the committed YAML
Q24How do you configure notifications, and what do on_success: change and on_failure: always actually mean?
IntermediateNotifications
Answer
The notifications key supports email, slack, webhooks, irc and a handful of legacy chat targets. Email defaults to the commit author and committer, which is why contributors sometimes receive mail from a repository they do not follow, and you override it with a recipients list. The two frequency keys are where the real behaviour lives. on_success defaults to change, meaning Travis only notifies when the result differs from the previous build on the same branch, so a run of green builds is silent and the first green after a red one is announced. on_failure defaults to always, so every red build notifies.
The other allowed values are always and never, and setting on_success: always on a busy repository is the fastest way to get your CI channel muted, which then hides real failures. For Slack you encrypt the workspace:token#channel triple with travis encrypt and put the blob under notifications.slack.rooms, and you can shape the message with template placeholders such as %{repository_slug}, %{branch}, %{author}, %{result}, %{duration} and %{build_url}. Webhooks are the integration path for anything internal: Travis POSTs a form-encoded payload with a Signature header, and you verify it against the public key served by the /config endpoint of the API rather than trusting the body, because the webhook URL is in a public config file and anyone can POST to it.
Webhooks also accept on_start, which the other channels do not. One production detail interviewers probe: notifications for a build fire once per build, not per job, so a matrix with one failing job produces a single message and your alert has to link to the build page for anyone to find which job broke.
notifications:
email:
recipients:
- devops@company.in
on_success: change # default: only when the status flips
on_failure: always # default
slack:
rooms:
# travis encrypt "team:xxxx#ci-alerts" --add notifications.slack.rooms
- secure: "Qk9y...base64...=="
on_success: change
on_failure: always
on_pull_requests: false
template:
- '%{repository_slug} %{branch} by %{author}'
- '%{result} in %{duration}: %{build_url}'
webhooks:
urls:
- https://hooks.internal.company.in/travis
on_start: always # only webhooks support on_start
on_success: always
on_failure: always
Key Points
- on_success defaults to change, on_failure defaults to always
- Email defaults to commit author and committer until you set recipients
- Slack rooms must be encrypted; templates accept %{result}, %{duration}, %{build_url}
- Verify the webhook Signature header against the API /config public key
Q25A team's Travis credit allowance runs out mid-month. Which config levers do you pull first?
IntermediateCost Control
Answer
Credits are consumed per job per minute of wall clock, including VM provisioning and cache download, and the multiplier depends on the platform, with Linux amd64 the cheapest and macOS and Windows meaningfully more expensive per minute. So the first move is arithmetic, not optimisation: open a recent build and count the jobs. The three biggest sources of waste, in the order I check them, are an env: list written where env: global: was meant, which silently multiplies every job; duplicate push and pull_request builds for same-repo branches; and a full multi-OS matrix running on every commit when the non-Linux platforms only need to run on the default branch and on cron.
After that, prune with jobs.exclude, move lint and audit work into a single include job instead of letting it run in every matrix cell, and switch Docker-only pipelines to language: minimal so you stop paying for a runtime image you never touch. Turn on auto-cancellation for pushes and pull requests so a rapid series of commits does not leave four superseded builds running to completion. Use a top-level if: so filtered events never boot a VM at all, since a job that is created and then skipped by a shell guard still costs provisioning time.
Caching helps, but only when the cache is actually smaller than the install it replaces, so measure it. Finally, stages pay for themselves on failing builds, because a failed test stage means the build and deploy stages never start. Concurrency limits are not a cost lever, they only queue jobs, so a team hitting the queue and a team hitting the credit wall need opposite fixes.
# Before: 3 node x 2 os x 2 env = 12 jobs on every commit
# After: 3 cheap Linux jobs per commit, the rest nightly.
if: type != push OR branch IN (main, develop) OR tag IS present
language: node_js
node_js: ['18', '20', '22']
os: [linux]
env:
global: # global, NOT a jobs list: no multiplication
- NODE_ENV=test
jobs:
include:
- name: 'lint + audit' # one job, not one per matrix cell
node_js: '20'
script: npm run lint && npm audit --audit-level=high
- name: 'macOS smoke'
os: osx
osx_image: xcode15.4
if: type = cron OR branch = main
script: npm test
Key Points
- Credits bill wall clock per job including provisioning, at a per-platform multiplier
- Check for env: used instead of env: global: before anything else
- Enable auto-cancellation and kill duplicate push plus pull_request builds
- Top-level if: avoids booting a VM at all; a shell guard inside the job does not
Q26How do you get an interactive shell into a failing Travis job, and what are the limits of debug mode?
IntermediateDebugging
Answer
Travis has a first-class debug mode that restarts a specific job with SSH access instead of running the lifecycle. You invoke it as travis debug --repo org/app 4123.4 with the job number, or by POSTing to /job/{job_id}/debug on API v3. The job boots the same image with the same environment, prints an ssh command in the log pointing at a tmate session, and then waits instead of executing your phases.
Inside the shell Travis defines helper functions that replay each phase exactly as the runner would: travis_run_before_install, travis_run_install, travis_run_before_script, travis_run_script and travis_run_after_script. That matters because running npm test by hand is not the same as running the phase, the phase applies the same environment setup, folding and exit-code handling. Three limits come up in interviews.
The session has a time cap in the tens of minutes and the job is billed for the whole time you sit in it, so it is not a place to leave a terminal open over lunch. Debug builds decrypt your secure environment variables, so anyone who can start one can read your CI credentials, which is why the feature is gated on repository permissions and why it should be treated as a privileged action. And the feature is not enabled for every account tier by default, notably on public repositories, where you may have to request it. When debug mode is not available, the fallbacks are printing more state in after_failure, running the same image locally with the published travis build images, or temporarily replacing script with a diagnostic command such as env, df -h and the version of every tool involved.
# Restart job 4123.4 with SSH access
# travis debug --repo org/app 4123.4
#
# Same thing straight against the API:
# curl -s -X POST -H 'Travis-API-Version: 3' \
# -H "Authorization: token $TRAVIS_TOKEN" \
# https://api.travis-ci.com/job/987654321/debug
#
# Then, inside the tmate session:
# travis_run_before_install
# travis_run_install
# travis_run_script # replay ONLY the phase that broke
# Fallback when debug mode is unavailable: dump state on failure.
after_failure:
- env | grep -v -i 'token\\|secret\\|key' | sort
- df -h && free -m
- node -v && npm -v && docker version || true
- tail -n 300 npm-debug.log 2>/dev/null || true
Key Points
- travis debug --repo org/app <job.number> restarts the job with a tmate SSH session
- travis_run_<phase> helpers replay each lifecycle phase faithfully
- Debug builds expose decrypted secrets, so treat the permission as privileged
- The session is time-capped and billed, and may need enabling for public repos
Q27How do you split a long test suite across Travis jobs and still report one coverage number?
IntermediateTesting
Answer
Travis has no built-in test splitting, so sharding is something you construct out of the env matrix plus your test runner's own partitioning. The pattern is an env.jobs list of shard identifiers, each job running the same command with a different shard argument. How you split matters more than the mechanism.
Splitting by file count gives badly unbalanced shards because a handful of integration files dominate the runtime, so use your runner's timing-based splitting where it exists (pytest-split with a stored durations file, Jest's shard flag, Go's package-level parallelism) and commit or cache the timings. A shard scheme must also be deterministic across jobs, since each job computes its own slice with no coordination, and a non-deterministic sort means some tests run twice and others never run at all, which is a silent hole in your gate. Coverage is the second half of the question.
Jobs share no filesystem, so each shard produces a partial report and something has to merge them. Codecov handles this natively: upload from each job with a distinct flag and it combines the reports for the commit. Coveralls needs COVERALLS_PARALLEL set on each upload plus a webhook call after the build to close the set, which is easiest from a final stage job.
If you self-host coverage, upload each partial file to S3 keyed by TRAVIS_BUILD_ID and merge in a later stage. Two failure modes to name: a shard that produces no tests still uploads an empty report and drags the number down, and a fork pull request has no upload token, so the coverage step must degrade rather than error.
language: python
python: ['3.12']
cache: pip
env:
jobs:
- SHARD=1 SHARDS=4
- SHARD=2 SHARDS=4
- SHARD=3 SHARDS=4
- SHARD=4 SHARDS=4
install:
- travis_retry pip install -r requirements.txt pytest-split pytest-cov
script:
# timing-based split, deterministic across jobs
- pytest -q --cov=app --cov-report=xml
--splits "$SHARDS" --group "$SHARD"
--durations-path .test_durations
after_success:
- |
if [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov -f coverage.xml -F "shard${SHARD}" -t "$CODECOV_TOKEN"
fi
Key Points
- No native splitting: build shards from env.jobs plus a runner-side partitioner
- Split by recorded durations, not file count, or one shard dominates the build
- The split must be deterministic or tests silently run twice or never
- Merge coverage with Codecov flags or Coveralls parallel plus a finish call
Q28What does Travis's default git clone do, and which tools break because of it?
IntermediateSource Control
Answer
Travis performs a shallow clone with a depth of 50 commits by default, checks out the commit under test in detached HEAD, and for pull requests it fetches and checks out a merge of the PR head into the target branch rather than the head itself. Every part of that trips something up. Tools that walk history break first: git describe fails with a message about no tags being able to describe the commit because the tag is older than 50 commits, semantic-release cannot find the previous release, SonarQube reports blame data as missing so new-code metrics are wrong, and a changelog generator produces a truncated list.
The fix is git.depth: false for a full clone, or a larger integer when you know the bound, at the cost of a slower checkout on a large repository. Submodules are cloned recursively by default, which fails for private submodules because the deploy key Travis injects only covers the main repository; set git.submodules: false and clone them yourself with an encrypted key, or rewrite the URLs to token form in before_install. Git LFS objects are smudged on checkout, which can be slow and can exhaust an LFS bandwidth quota on every matrix job, so git.lfs_skip_smudge: true and an explicit pull of only what you need is the usual production setting.
Related is TRAVIS_COMMIT_RANGE, which is the range of commits in a push, empty on the first build of a new branch and on API and cron triggers, and pointing at commits that no longer exist after a force push. Any script that runs git diff on it must handle all three cases or it errors the build.
git:
depth: false # full history: git describe, semantic-release, Sonar blame
submodules: false # private submodules need your own key
lfs_skip_smudge: true
quiet: true
before_install:
# Fetch the tags a shallow clone would have dropped
- git fetch --tags --unshallow || git fetch --tags
# Private submodules with an encrypted deploy key
- |
if [ -f .gitmodules ] && [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then
sed -i 's|git@github.com:|https://'"$GH_TOKEN"'@github.com/|g' .gitmodules
git submodule update --init --recursive
fi
script:
# TRAVIS_COMMIT_RANGE is empty on new branches, cron and API builds,
# and can reference missing commits after a force push.
- |
if [ -n "$TRAVIS_COMMIT_RANGE" ] && git rev-parse "${TRAVIS_COMMIT_RANGE%%.*}" >/dev/null 2>&1; then
CHANGED=$(git diff --name-only "$TRAVIS_COMMIT_RANGE")
else
CHANGED=$(git ls-files)
fi
echo "$CHANGED" | head -50
Key Points
- Default clone is shallow at depth 50 with a detached HEAD, PRs build a merge commit
- git describe, semantic-release and Sonar blame need git.depth: false
- Private submodules fail on the injected deploy key; disable and clone them yourself
- TRAVIS_COMMIT_RANGE is empty on new branches, cron and API builds
Q29How do build config imports work, and how would you share one CI policy across forty repositories?
AdvancedConfiguration
Answer
Build config imports are the answer to copy-pasted YAML across an organisation. With version: ~> 1.0 declared at the top of the file, you can use the import key to pull config fragments from another path in the same repository, from another repository at a specific ref, or from a public URL, and Travis resolves them server-side before the build is created. Each import entry takes a source in the form owner/repo:path/file.yml@ref, a mode, and optionally an if condition so the fragment only applies to certain events.
The modes are what make it usable: merge replaces keys wholesale, deep_merge merges nested maps, and deep_merge_append and deep_merge_prepend control whether an imported list joins before or after the local one, which is how you force an organisation-wide security scan to run first in every repository's script list without erasing the local commands. Imports can be nested, so a platform team ships a base file that itself imports a language-specific fragment, and the expanded result is visible in the job's Config tab, which is the only reliable way to debug a surprising merge. Operationally, three things matter.
Pin the ref, because importing from a moving branch means a change in the shared repository can break every pipeline in the organisation simultaneously with no commit in the affected repositories. The importing repository's user needs read access to the source repository, and a private source silently fails to resolve for anyone who does not. And because imports resolve before the VM boots, you cannot compute the source dynamically. In practice most organisations version the shared config like a library, tagging v3, v4 and so on, and open a small pull request in each repository to move the pin.
version: ~> 1.0
import:
# Pin the ref. Never import from a moving branch.
- source: platform/ci-shared:node/base.yml@v4.2.0
mode: deep_merge
# Org-wide security scan, forced to the FRONT of the local script list
- source: platform/ci-shared:policy/scan.yml@v4.2.0
mode: deep_merge_prepend
if: type = push OR type = cron
language: node_js
node_js: ['20']
script:
- npm run lint
- npm test
# Expanded result is shown in the job's Config tab; verify with:
# travis lint .travis.yml
Key Points
- Requires version: ~> 1.0; sources are owner/repo:path@ref, a local path or a URL
- merge, deep_merge, deep_merge_append and deep_merge_prepend control list ordering
- Pin the ref, or one upstream commit breaks every repository at once
- Resolution is server-side and pre-boot, so nothing can be computed at build time
Q30Travis has no path filters. How do you stop a monorepo from running every suite on every commit?
AdvancedMonorepo
Answer
This is the honest weak spot of Travis compared with newer systems, which is exactly why it gets asked. There is no paths: key, so you cannot avoid creating a job based on which files changed, and the condition language cannot see the diff. Everything you do is inside the job, after you have already paid for the VM boot.
The workable pattern has three parts. First, compute the changed set defensively from TRAVIS_COMMIT_RANGE, falling back to a full run when the range is empty (new branch, cron, API trigger) or references commits that no longer exist after a force push, because treating a broken range as 'nothing changed' means a silently untested merge, which is far worse than an unnecessary build. On pull requests, diffing against the merge base of the target branch is more accurate than the range.
Second, keep the matrix aligned with the service boundaries, one job per package, so a skipped job is one cheap early exit rather than a wasted twenty minutes. Third, exit early and loudly: print which packages matched and which were skipped, so a green build is auditable and nobody wonders whether the suite ran. The alternative many teams adopt is generating .travis.yml, either with build config imports pulling one fragment per package, or by having a small job call the API v3 requests endpoint with a config overlay listing only the affected packages. Interviewers often close by asking about the correctness risk, and the answer is that path filtering weakens your gate: a shared library change that should trigger every dependent package must be encoded in your filter, or you ship a break that CI declared green.
language: node_js
node_js: ['20']
env:
jobs:
- PKG=api
- PKG=web
- PKG=worker
before_script:
- |
set -e
BASE="origin/${TRAVIS_BRANCH}"
if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
git fetch origin "$TRAVIS_BRANCH" --depth=200
RANGE="$(git merge-base FETCH_HEAD HEAD)...HEAD"
elif [ -n "$TRAVIS_COMMIT_RANGE" ] \
&& git rev-parse "${TRAVIS_COMMIT_RANGE%%.*}" >/dev/null 2>&1; then
RANGE="$TRAVIS_COMMIT_RANGE"
else
RANGE="" # unknown history: run EVERYTHING, never assume no-op
fi
if [ -z "$RANGE" ]; then
echo 'RUN=1' > .decision
elif git diff --name-only "$RANGE" \
| grep -Eq "^(packages/${PKG}/|packages/shared/|package-lock.json)"; then
echo 'RUN=1' > .decision
else
echo 'RUN=0' > .decision
fi
cat .decision
script:
- . ./.decision
- if [ "$RUN" = "1" ]; then npm -w "packages/$PKG" test; else echo "skip $PKG"; fi
Key Points
- No paths: key, so filtering happens inside the job after you pay for boot
- An empty or broken TRAVIS_COMMIT_RANGE must mean run everything, not skip
- Diff against the merge base on PRs, not the raw range
- Shared-library paths must be in every package's filter or the gate has a hole
Q31A job dies with exit code 137 or 'no space left on device'. How do you diagnose and fix it?
AdvancedPerformance
Answer
Exit 137 is 128 plus signal 9, so the kernel's OOM killer terminated a process. In Travis this almost never shows a helpful message, the log just stops mid-test and the job goes red, and the tell is that it is intermittent and correlates with how many test workers you spawn. Confirm it by printing free -m before and after the suite and grepping dmesg for the kill line.
The root cause is usually that a test runner defaults its worker count to the number of visible CPUs, and each worker is a full Node, Python or JVM process. A Travis Linux job gives you a modest fixed slice of cores and memory, and you should read the actual figures from the job log's hardware line rather than trusting a remembered number, because they differ by image, by virt setting and between hosted and Enterprise workers. The fixes in order: cap the worker count explicitly (jest --maxWorkers=2, pytest -n 2, Maven -T 1C is not enough, set the fork count), cap the heap so the runtime garbage collects instead of ballooning (NODE_OPTIONS=--max-old-space-size, JVM -Xmx, and remember the JVM in a container may not read the cgroup limit correctly on older images), and only then consider virt: vm for a larger machine or sharding across jobs.
Disk exhaustion is the sibling failure and is most common in Docker pipelines, because multi-stage builds, pulled base images and a growing cache all share one modest volume. Print df -h at the start, prune aggressively between steps with docker system prune -af, and make sure before_cache is not shipping build output into the cache tarball, which quietly eats disk on every subsequent run.
before_script:
- free -m && df -h && nproc
script:
# Cap workers AND heap; defaults assume a dev laptop, not a CI slice.
- export NODE_OPTIONS=--max-old-space-size=2048
- npx jest --maxWorkers=2 --runInBand=false --logHeapUsage
- docker system prune -af --volumes # reclaim between image builds
- docker build -t app:$TRAVIS_COMMIT .
after_failure:
- free -m && df -h
# The OOM kill evidence, if the kernel logged it:
- dmesg 2>/dev/null | grep -i -E 'killed process|out of memory' | tail -20
before_cache:
# Do not cache build output; it grows without bound and fills the disk.
- rm -rf dist build .next/cache/webpack
Key Points
- Exit 137 is SIGKILL from the OOM killer, not a test framework error
- Test runners size worker pools from visible CPUs and overcommit the job's RAM
- Cap workers and heap first; virt: vm and sharding are the later levers
- Docker pipelines exhaust disk: prune between builds and never cache build output
Q32Explain how Travis schedules queued jobs, and what auto-cancellation actually cancels.
AdvancedScheduling
Answer
Two separate limits govern how quickly your work starts. The plan's concurrency limit caps how many jobs your account runs simultaneously across all repositories, and the queue is fair-shared, so one repository pushing a fifty-job matrix does not permanently starve another, but it will absolutely delay it. Beyond your own limit there is platform capacity for the specific image you asked for, which is why an s390x, macOS or Windows job can sit queued for far longer than a Linux amd64 job even when your concurrency is free.
Distinguishing the two matters in an incident: if your other jobs are running, you are waiting on platform capacity, not on your plan. Auto-cancellation, configured per repository in settings and via the API, has two independent switches. Auto-cancel pushes cancels older still-queued or running builds for the same branch when a newer push arrives, and auto-cancel pull requests does the same per pull request.
Two details catch people. It cancels the whole build, including jobs that are already mid-run, so a partially uploaded artifact or a half-finished deploy can be left behind, which is why deployment belongs in a stage guarded by conditions rather than in the middle of a test job. And it is scoped to the same branch or pull request, so it does nothing about the duplicate push plus pull_request pair for one commit, which needs the config-level fix. Cancelled builds show as cancelled, not failed, and any external system consuming the status API should treat those as neutral rather than red, otherwise your deployment dashboard fills with phantom failures every time somebody pushes twice in a minute.
# Read the current auto-cancel settings (API v3)
# curl -s -H 'Travis-API-Version: 3' \
# -H "Authorization: token $TRAVIS_TOKEN" \
# https://api.travis-ci.com/repo/org%2Fapp/settings
#
# Enable auto-cancellation for superseded pushes:
# curl -s -X PATCH -H 'Travis-API-Version: 3' \
# -H 'Content-Type: application/json' \
# -H "Authorization: token $TRAVIS_TOKEN" \
# -d '{"setting.value": true}' \
# https://api.travis-ci.com/repo/org%2Fapp/setting/auto_cancel_pushes
# Make deploys safe against mid-run cancellation: isolate them in a stage
# that only starts once every test job has already finished.
stages:
- test
- name: deploy
if: type = push AND branch = main
jobs:
include:
- stage: deploy
script: skip
deploy:
provider: script
script: ./scripts/deploy.sh # idempotent, safe to re-run
on:
branch: main
Key Points
- Plan concurrency and per-image platform capacity are two different queues
- auto_cancel_pushes and auto_cancel_pull_requests are separate repository settings
- Cancellation kills running jobs mid-step, so deploys must be isolated and idempotent
- Cancelled is a distinct status; downstream dashboards should not treat it as failed
Q33How would you harden a Travis pipeline against credential leakage?
AdvancedSecurity
Answer
Start from the platform's known weaknesses rather than a generic checklist. Travis has had two well-publicised classes of problem: a 2021 disclosure in which secure environment variables could reach pull request builds on public repositories, and later research showing that credentials left in publicly readable job logs remained retrievable through the logs API long after teams believed them gone. Both point to the same conclusion, that a CI secret should be scoped and short-lived enough that exposure is a contained incident.
Concretely: prefer repository settings variables over secure: blobs in the YAML, because you can rotate them without a commit and they never live in git history. Turn off 'available to pull requests' for anything with write scope. Give each pipeline its own credential with the narrowest possible permission, a registry push token for one repository rather than an org-wide personal access token, and an IAM role limited to one bucket prefix rather than a shared deploy key.
Rotate on a schedule, and treat any secret that has ever appeared in a public log as burned rather than reasoning about who might have seen it. Remember that log masking is exact-string matching only, so anything you base64, split across lines, or pass through set -x reappears in the clear, which is why set +x around credential handling and --password-stdin for docker login are not optional. Guard every secret-using step on TRAVIS_SECURE_ENV_VARS so fork pull requests degrade cleanly instead of erroring in a way that pushes people to loosen the setting. And because Travis has no OIDC federation to cloud providers of the kind newer runners offer, long-lived static keys are unavoidable, which makes rotation discipline the whole defence.
before_script:
- set +x # never echo the credential handling
- |
if [ "$TRAVIS_SECURE_ENV_VARS" != "true" ]; then
echo 'fork PR: no credentials, running read-only checks'
export SKIP_PUBLISH=1
else
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
# Exchange the long-lived key for a short-lived session immediately
eval "$(aws sts assume-role \
--role-arn arn:aws:iam::123456789012:role/ci-artifact-writer \
--role-session-name "travis-$TRAVIS_BUILD_ID" \
--duration-seconds 1800 \
--query 'Credentials.[
`export AWS_ACCESS_KEY_ID=`+AccessKeyId,
`export AWS_SECRET_ACCESS_KEY=`+SecretAccessKey,
`export AWS_SESSION_TOKEN=`+SessionToken]' --output text | tr '\\t' '\\n')"
fi
- set -x
before_cache:
- rm -f ~/.docker/config.json ~/.npmrc ~/.aws/credentials
Key Points
- Settings variables over secure: blobs, because rotation needs no commit
- Log masking is exact-string only; encoding, splitting or set -x defeats it
- No OIDC federation, so scope static keys tightly and exchange them for short sessions
- Anything that reached a public build log is burned, rotate rather than assess
Q34You are asked to migrate a mature .travis.yml to GitHub Actions. What maps cleanly and what does not?
AdvancedMigration
Answer
Most of it maps mechanically. Lifecycle phases become ordered run steps, since Actions has no before_install or after_success and a non-zero exit stops the job unless you set continue-on-error, so the errored-versus-failed distinction disappears and script lists that relied on running every entry need explicit handling. The matrix maps to strategy.matrix with include and exclude behaving similarly, allow_failures becomes continue-on-error on the matrix entry, and fast_finish is roughly the inverse of fail-fast.
Stages become separate jobs wired with needs, and because Actions jobs also do not share a filesystem you keep the same artifact discipline, except that upload-artifact and download-artifact give you a first-class path where Travis made you use S3. Caching is the biggest genuine improvement: actions/cache takes an explicit key you compose from a lockfile hash, which removes the manual travis cache --delete ritual entirely. Secrets map to repository or environment secrets, and fork pull requests are still restricted, though pull_request_target and environment approvals give you controlled options Travis lacks.
What does not map cleanly: travis_retry has no builtin equivalent, dpl deploy providers have to be replaced with actions or plain CLI calls, travis_wait is unnecessary because the timeout model is different (a job timeout you set in minutes, with no ten-minute silence watchdog), and s390x and ppc64le have no hosted equivalent, which is precisely why some projects never migrated. Path filters, reusable workflows and OIDC to cloud providers are all things you gain. The migration risk worth naming in an interview is behavioural, not syntactic: conditions, the TRAVIS_BRANCH semantics on pull requests, and the every-script-entry-runs rule all differ, so a config that looks equivalent can quietly deploy from the wrong ref.
# .travis.yml
# language: node_js
# node_js: ['18','20']
# cache: npm
# install: travis_retry npm ci
# script:
# - npm run lint
# - npm test
# deploy:
# provider: npm
# on: { tags: true }
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # ~ the opposite of fast_finish
matrix:
node: ['18', '20']
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # Travis default was depth 50
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm # lockfile-hashed key, unlike Travis
- run: npm ci
- run: npm run lint
- run: npm test
publish:
needs: test # ~ a Travis stage
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Key Points
- Phases become ordered steps; errored versus failed and 'all script entries run' are lost
- Stages become jobs with needs; allow_failures becomes continue-on-error
- actions/cache keys on a lockfile hash, removing manual cache invalidation
- No equivalent for travis_retry, dpl providers, or s390x and ppc64le runners
Q35How does Travis CI Enterprise differ operationally from the hosted service, and what do you monitor?
AdvancedEnterprise
Answer
Travis CI Enterprise is the self-hosted deployment, and it is the reason Travis still appears in Indian job descriptions at banks, insurers and regulated financial services firms, where source code cannot reach a public runner. Architecturally it splits into platform services and workers. The platform side runs the web UI, the API, the scheduler and the log processor, backed by PostgreSQL for build metadata and a message broker for job dispatch, and it integrates with a GitHub Enterprise instance rather than github.com.
Workers are separate machines that pull jobs off the queue and run each one in a fresh container or VM from a build image you host locally. That separation drives everything you operate. Capacity is yours to size, so the concurrency limit is a function of how many worker machines you provisioned, not a plan tier, and there is no credit model, licensing is per active user.
Build images do not update themselves, so a team can be running an image with an end-of-life Ubuntu and an old Node until someone syncs new ones, and 'works on hosted Travis, fails on ours' almost always traces to an image version gap. Logs live on your storage, which means retention and disk growth become your problem, and log processing backlogs are a classic incident where builds appear stuck at zero output while actually running fine. The things worth alerting on are queue depth and time-to-start (the honest measure of whether you have enough workers), worker health and orphaned containers left behind by cancelled jobs, Postgres and broker health, disk on the log store, and licence seat usage. Backups of the platform database matter because build history and encrypted settings variables live there.
Key Points
- Platform services (UI, API, scheduler, Postgres, broker) are separate from worker machines
- Integrates with GitHub Enterprise; concurrency is worker capacity, licensing is per user
- Build images are yours to sync, and stale images cause hosted-versus-local divergence
- Alert on queue depth, time-to-start, worker health, log-store disk and DB backups
Frequently Asked Questions
What salary can I expect in India with Travis CI on my CV?
Travis CI on its own is not a salary driver anywhere in India, and you should not position it as your headline skill. It contributes as part of a CI/CD and platform-engineering profile, where the honest bands are roughly ₹6-10 LPA for a DevOps engineer with one to three years, ₹12-20 LPA at four to seven years, and above that for platform or SRE roles where you own reliability and cost as well as pipelines. Service companies such as Infosys, TCS, Wipro, HCLTech, LTIMindtree and Accenture India are where Travis appears most often, usually on a client's legacy estate, and those roles sit at the lower to middle end with structured banding. Product companies and GCCs pay more but rarely list Travis specifically, they list CI/CD and expect you to be fluent in whichever system the team runs. The lever that actually moves your number is being the person who can own a legacy pipeline and migrate it safely, because that is scarce and directly saves money.
How long does it take to prepare for a Travis CI round?
If you already work with CI/CD daily, two focused evenings are enough to be interview-ready. Spend the first on the lifecycle and the matrix, because the errored-versus-failed distinction, the cartesian expansion and the include-versus-exclude semantics account for a large share of questions, and they are easy to get wrong from memory. Spend the second on stages, secrets and cost, which is where mid-level rounds go. If you have never used Travis, budget about a week and build something real: take a small public project of your own, write a .travis.yml with three matrix jobs, a cache, an encrypted variable and a tag-gated deploy, then deliberately break each one and read the logs. Running travis lint, travis env set and travis cache --delete against a live repository teaches you more in an hour than any reading, and it gives you concrete stories, which is what an interviewer is actually listening for.
How do Travis CI questions differ for freshers and experienced candidates?
A fresher is asked what the YAML keys do. Expect the lifecycle order, what install and script default to for a given language, how the matrix expands, what TRAVIS_BRANCH holds on a pull request, and how to add an encrypted variable. Clear explanations with a small correct example are enough, and nobody expects you to have operated a large estate. From about three years, the questions turn into scenarios: this build is green but ran no tests, explain why; the credit allowance ran out on the eighteenth of the month, what do you change; a deploy fired from a feature branch, how did that happen; the same test passes locally and fails on CI with no code change. Senior rounds move to ownership: migration planning, secret scoping and rotation, monorepo filtering trade-offs, and whether you can articulate what you lose by moving off Travis rather than just how to move.
Is Travis CI still worth learning in 2026?
Not as a first CI system. Learn GitHub Actions first if you are starting out, because that is what new Indian projects use and what most job descriptions assume. Travis is worth learning in three situations. If you are joining a team with an existing Travis estate, which is common in service companies maintaining client platforms and in older open-source codebases. If you are targeting migration work, where knowing both sides well enough to spot behavioural differences is a genuinely marketable skill. And if you work somewhere running Travis CI Enterprise behind a firewall, which happens in Indian banking and regulated financial services. The underlying concepts transfer almost completely: lifecycle phases, matrix expansion, caching strategy, secret handling on fork pull requests, and artifact passing between jobs are the same problems in every CI system. Time spent understanding why Travis behaves the way it does is not wasted even if you never write another .travis.yml.
Travis CI, Jenkins or GitHub Actions: which should I put on my resume?
List whichever you have genuinely operated, and be ready to compare them, because the comparison question is nearly guaranteed. The useful framing is control versus convenience. Jenkins gives you total control and total operational burden: you run the controller, the agents, the plugin upgrades and the security patching, and it is still extremely common in Indian enterprises and in anything on-premises. GitHub Actions gives you the tightest repository integration, a marketplace, reusable workflows, path filters and OIDC federation to cloud providers, at the cost of being tied to GitHub. Travis sits between them historically, config-as-code in one file with a hosted runner, and its remaining differentiators are exotic architectures and the Enterprise deployment. If you can say clearly why a team would pick each one, and name one thing each does badly, you are doing better than a candidate who has only used one and calls it the best.
Which projects should I build to prove Travis CI skill in an interview?
Three small ones beat one large one. First, a multi-stage pipeline on a real repository: a test stage with a three-version matrix, a build stage that publishes an artifact to S3 or GitHub Releases, and a deploy stage gated on tags, which demonstrates that you understand stages have no shared filesystem. Second, a deliberately hardened config: an encrypted variable, a fork-pull-request degradation path guarded on TRAVIS_SECURE_ENV_VARS, and a before_cache that scrubs credential files. Third, a migration write-up: take one of the first two, port it to GitHub Actions, and document what changed behaviourally rather than syntactically, especially around failure semantics and branch variables. Put the repository links on your CV and be ready to walk through a build log. Talking through an actual log, pointing at the phase timings and explaining a failure, is far more convincing than reciting keys.
Introduction
Travis CI is the hosted continuous integration service that popularised the idea of a single YAML file in the repository root driving the whole build. In 2026 it is no longer the default choice for greenfield projects, GitHub Actions took that spot after travis-ci.org shut down and the remaining travis-ci.com platform moved to a credit-based billing model. It is very much alive in three places, though: long-lived enterprise pipelines nobody has had budget to migrate, projects that need Linux on s390x, ppc64le or arm64 alongside macOS and Windows in one matrix, and Travis CI Enterprise installations sitting inside banks and regulated firms that cannot ship source to a public runner.
That mix is exactly why Travis CI still shows up in Indian job descriptions, usually alongside Jenkins, GitHub Actions and Docker rather than on its own. Interviewers rarely ask you to recite YAML keys. They probe whether you understand the job lifecycle and why a failure in before_install errors the build while the same failure in script only fails it, how the matrix expands and how much that costs in credits, why secure environment variables vanish on pull requests from forks, how caching actually gets keyed and invalidated, and what breaks when you migrate a five-year-old config to another platform. Legacy-pipeline ownership is a real, paid responsibility.
This guide covers 35 Travis CI interview questions asked in 2026, ordered from fundamentals to advanced production work. Each answer explains the real behaviour rather than the documentation summary, names the exact keys, CLI commands, TRAVIS_ variables and error conditions involved, and calls out the production gotchas interviewers use as follow-ups. Most questions carry a working .travis.yml or shell snippet you can paste and adapt. Work through the basic section to get the lifecycle and matrix model right, then focus on stages, secrets, credit control, debugging and migration, which is where mid and senior offers are actually decided.
Ready to practice Travis CI interviews?
Don't just read, practice these Travis CI questions live with an AI interviewer that asks follow-ups and scores your answers.