CoursesJenkins foundations, done rightController & agent architecture

Controller & agent architecture

Why builds never run on the controller.

Beginner14 min · lesson 2 of 16

You push a commit. Thirty seconds later a machine somewhere is compiling your code and running your tests. Which machine? In Jenkins the answer is never one box. A busy restaurant kitchen splits the work the same way: the head chef takes the orders, keeps the recipes, and holds the key to the wine cellar, and she does not stand at the stove. The line cooks cook. Jenkins calls the head chef the controller. It serves the web interface (the UI, or user interface, the pages you log into), stores every job definition and every secret, and decides what runs where, and in a healthy setup it runs no builds at all. Jenkins calls the line cook an agent (older documentation says node; the retired term was slave). An agent is a worker machine that connects to the controller and actually runs your build steps: checkout, compile, test, package. The controller decides. The agent does the work. Getting that split right is the difference between a Jenkins that runs and a Jenkins that is safe.

Controller, agents, and executors
Controller: orchestrates, holds secrets (0 executors)
web UI + API
where you configure and watch
scheduler
hands each queued build to a free executor
JENKINS_HOME
config + history + credentials store
Agent linux-1: runs builds, untrusted
executor 1
one build at a time
executor 2
one build at a time
Agent docker-2: ephemeral
executor 1
fresh container, destroyed after the build
The scheduler on the controller hands each queued build to a free executor on an agent. The controller keeps none for itself, which is what contains a poisoned build to a disposable worker.

The controller holds the keys to everything

Everything worth stealing sits on the controller, inside one directory called JENKINS_HOME (usually /var/jenkins_home). Treat that directory like the safe behind the manager's desk. It holds your job configurations, the complete build history, the installed plugins, and the credentials store: the SSH keys (secure shell, the standard way to log into a remote machine), the cloud tokens, and the registry passwords your pipelines use. The controller also runs the scheduler, the part that watches the queue of waiting builds and hands each one to a free executor, and it hosts the remoting channel that every agent connects back to. It can read every secret and rewrite every job. So anyone who gets code running on the controller owns your CI system (continuous integration, the machinery that builds and tests every commit) and everything it ships. That one fact drives every decision in this lesson.

Treat every agent as untrusted

An agent runs arbitrary code, so you treat it as hostile from day one. A build is whatever happens to be in the repository being built: the Jenkinsfile, the test suite, and every third-party dependency they drag in, none of which you wrote or reviewed. Any one of those can be malicious, or perfectly honest and quietly compromised upstream last week. Run that code on the controller and it can read the credentials store, rewrite jobs, and take over Jenkins. Run it on a throwaway agent and the damage stops at one worker you can destroy and rebuild in a minute. That gives you the most important architectural rule in Jenkins, which happens to be a security rule too: builds never run on the controller. The controller orchestrates. Agents build.

Executors, and why the controller gets zero

An executor is a seat at the table. One seat, one build at a time. A machine with two executors runs two builds side by side, and a third build waits until a seat frees up. Here is the trap. A fresh Jenkins gives the built-in node (the controller wearing a second hat, acting as a worker) two executors by default. Straight out of the box, your builds run on the controller. The fix is to set the built-in node to zero executors. No seats, no builds, and every piece of work is pushed onto agents. You can do it by clicking Manage Jenkins, then Nodes. The repeatable way is Configuration as Code, shortened to JCasC: a plugin that configures Jenkins from a YAML file (a plain-text settings file) kept in version control, so the setting cannot be quietly undone in the web interface.

jenkins.yaml
# jenkins.yaml — JCasC applies this on boot. Point CASC_JENKINS_CONFIG at the
# directory ($JENKINS_HOME/casc_configs) that holds it; with no env var the plugin
# defaults to a single $JENKINS_HOME/jenkins.yaml. Version-controlled, so a UI edit
# cannot quietly re-enable builds on the controller.
jenkins:
systemMessage: "Builds run on agents only. The controller orchestrates."
numExecutors: 0 # the built-in (controller) node gets ZERO build slots
mode: EXCLUSIVE # only run work whose label explicitly targets this node
labelString: "built-in"
security:
sSHD:
port: -1 # disable the controller's CLI-over-SSH port; not needed here
jenkins-boot.log (expected output)
2025-11-12 09:12:44.301+0000 [id=42] INFO i.j.p.casc.ConfigurationAsCode#configure: Configuring Jenkins from: /var/jenkins_home/casc_configs/jenkins.yaml
2025-11-12 09:12:45.190+0000 [id=21] INFO jenkins.InitReactorRunner$1#onAttained: Completed initialization
2025-11-12 09:12:45.244+0000 [id=21] INFO hudson.WebAppMain$3#run: Jenkins is fully up and running
# Built-in node now shows 0 executors — nothing can be scheduled on the controller.

Wiring up your first agent

With the controller locked down, bring in a worker. Agents connect in one of two directions, and your network decides which one you get. With an SSH agent the controller places the call: it opens an outbound SSH connection to the agent on port 22 and starts the worker process itself. Tidy, as long as the controller can reach the agent. With an inbound agent (older docs call it JNLP, after Java Network Launch Protocol, the Java startup mechanism it used years ago) the direction flips, like a branch office phoning head office and keeping the line open. The agent dials the controller and holds the connection. Modern inbound agents run over WebSocket (a long-lived two-way connection) on the same HTTPS port as the web interface, so they need no extra holes punched in the firewall. That is what you want when the agent sits behind a firewall or NAT (network address translation, the router trick that hides a whole private network behind one public address) that blocks anything arriving from outside. The agent can reach out even when the controller cannot reach in. You start one by running a small Java program, agent.jar, with a one-time secret the controller generated.

connect-agent.sh
# Run on the AGENT host. It dials out to the controller — no inbound ports needed.
curl -sO https://ci.example.com/jnlpJars/agent.jar
java -jar agent.jar \
-url https://ci.example.com/ \
-name linux-1 \
-secret 9f3c1a7e0b6d4c28f5a1e9b7c0d2f4a6 \
-workDir /home/jenkins/agent \
-webSocket
agent.log (expected output)
Nov 12, 2025 9:14:02 AM hudson.remoting.jnlp.Main createEngine
INFO: Setting up agent: linux-1
Nov 12, 2025 9:14:02 AM hudson.remoting.Engine startEngine
INFO: Using Remoting version: 3283.v92c105e0f819
Nov 12, 2025 9:14:02 AM hudson.remoting.Engine startEngine
INFO: Using /home/jenkins/agent/remoting as a remoting work directory
Nov 12, 2025 9:14:03 AM hudson.remoting.jnlp.Main$CuiListener status
INFO: Connecting to ci.example.com over WebSocket
Nov 12, 2025 9:14:03 AM hudson.remoting.jnlp.Main$CuiListener status
INFO: WebSocket connection open
Nov 12, 2025 9:14:04 AM hudson.remoting.jnlp.Main$CuiListener status
INFO: Connected

The agent is online and wearing the label linux, so now you can aim a build at it. A label is a sticker you put on one or more agents so a pipeline can ask for a kind of machine instead of a named box: any Linux worker, rather than the one called linux-1. In a declarative Jenkinsfile (the structured pipeline file Jenkins reads straight out of your repository) the agent directive does the asking. When the build starts, the console log prints the node it landed on and the workspace path it used. That line is your receipt: proof the work ran on the agent and not on the controller.

Jenkinsfile
pipeline {
agent { label 'linux' } // run on any agent labelled 'linux' — never the controller
stages {
stage('Build') {
steps {
sh 'echo "building on $(hostname)"'
sh 'mvn -B package'
}
}
}
post {
always {
echo "ran on node: ${env.NODE_NAME}"
}
}
}
build #17 console (expected output)
Started by user Sachin
[Pipeline] Start of Pipeline
[Pipeline] node
Running on linux-1 in /home/jenkins/agent/workspace/webapp
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Build)
[Pipeline] sh
+ echo building on linux-1
building on linux-1
[Pipeline] sh
+ mvn -B package
[INFO] BUILD SUCCESS
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Declarative: Post Actions)
[Pipeline] echo
ran on node: linux-1
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

Two trade-offs shape real deployments. Ephemeral agents are the paper plates of continuous integration: a fresh container or cloud virtual machine (VM) started for one build and thrown away afterwards. Every build gets a clean workspace, and anything a poisoned build left behind dies with the machine. The bill is a few seconds of startup time and a cloud plugin to manage the lifecycle. The second trade-off is quieter. Every plugin you install to launch or manage agents runs on the controller, so each one widens the attack surface of the machine you care most about. Install what you need and nothing else. None of this replaces the daily habit either. Pipelines should reach for secrets through credentials binding, which masks them as asterisks in the console log and decrypts them only on the controller. Never echo a secret where an agent log could catch it.

The three failures you will actually hit

Almost every support ticket is one of three things. First, the agent shows offline. Nine times out of ten the secret is wrong or was regenerated, so copy a fresh one from the node page. If the secret is right, look at the clock: a drifted clock breaks the TLS handshake (Transport Layer Security, the encryption behind HTTPS), so keep NTP (Network Time Protocol, which keeps machine clocks in sync) running. Second, the build log says Running on Jenkins in /var/jenkins_home/ instead of naming your agent. That Jenkins is the built-in node, so the build landed on the controller, either because you forgot to zero its executors or because your label matched nothing. Third, a build sits at pending, Waiting for next available executor, forever. No online agent carries the label you asked for, so hunt for a typo or a dead agent. When a connection misbehaves, read the agent.jar output on the agent itself. It usually names the problem in the first few lines.

Zero executors is only half the lock
Setting the built-in node to 0 executors stops the scheduler from placing work there. It does not stop a teammate from bumping it back to 2 during a bad afternoon and quietly reopening the door. Pair numExecutors: 0 with mode: EXCLUSIVE, so the controller only ever accepts builds whose label points at the built-in node by name, which nothing legitimate ever should. There is a flip side to this. Once the controller cannot build and every agent is offline, builds pile up in the queue instead of failing loudly, so alert on agents dropping offline and on queue items older than a few minutes.
Quick check
01Your build agent lives inside a corporate network. The firewall blocks every inbound connection to it but allows outbound HTTPS. How should it connect to the Jenkins controller?
Incorrect — SSH launching needs the controller to reach the agent, and this firewall blocks exactly that.
Correct — The agent only ever makes outbound connections, so it works fine from behind a firewall or NAT.
Incorrect — That puts builds on the machine holding every secret, the one thing this architecture exists to prevent.
Incorrect — Not true. Inbound agents exist for exactly this network shape.
02What makes the Jenkins controller the machine an attacker most wants to land on?
Incorrect — No. You want zero executors on the controller; running builds there is the thing to avoid.
Correct — The controller keeps the SSH keys, tokens, and job definitions, so code execution there is total compromise.
Incorrect — No. Agents do the real build work, compiling included, while the controller only orchestrates.
Incorrect — No. Build history does live there, but its value as a target is the credentials and the control, not artifact storage.
03A build's console log opens with 'Running on Jenkins in /var/jenkins_home/workspace/webapp' instead of naming your agent. What actually happened?
Incorrect — No. 'Jenkins' next to a /var/jenkins_home path is the built-in node, not your agent.
Incorrect — No. The log shows the build did run. It ran in the wrong place.
Correct — That path is the controller's JENKINS_HOME, so the work landed on the controller instead of an agent.
Incorrect — No. That line names where the build executed, not an archiving copy.

Try this

Work through “The three failures you will actually hit” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: zero executors is only half the lock. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related