Controller & agent architecture
Why builds never run on the controller.
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.
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 — 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 slotsmode: EXCLUSIVE # only run work whose label explicitly targets this nodelabelString: "built-in"security:sSHD:port: -1 # disable the controller's CLI-over-SSH port; not needed here
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.yaml2025-11-12 09:12:45.190+0000 [id=21] INFO jenkins.InitReactorRunner$1#onAttained: Completed initialization2025-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.
# Run on the AGENT host. It dials out to the controller — no inbound ports needed.curl -sO https://ci.example.com/jnlpJars/agent.jarjava -jar agent.jar \-url https://ci.example.com/ \-name linux-1 \-secret 9f3c1a7e0b6d4c28f5a1e9b7c0d2f4a6 \-workDir /home/jenkins/agent \-webSocket
Nov 12, 2025 9:14:02 AM hudson.remoting.jnlp.Main createEngineINFO: Setting up agent: linux-1Nov 12, 2025 9:14:02 AM hudson.remoting.Engine startEngineINFO: Using Remoting version: 3283.v92c105e0f819Nov 12, 2025 9:14:02 AM hudson.remoting.Engine startEngineINFO: Using /home/jenkins/agent/remoting as a remoting work directoryNov 12, 2025 9:14:03 AM hudson.remoting.jnlp.Main$CuiListener statusINFO: Connecting to ci.example.com over WebSocketNov 12, 2025 9:14:03 AM hudson.remoting.jnlp.Main$CuiListener statusINFO: WebSocket connection openNov 12, 2025 9:14:04 AM hudson.remoting.jnlp.Main$CuiListener statusINFO: 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.
pipeline {agent { label 'linux' } // run on any agent labelled 'linux' — never the controllerstages {stage('Build') {steps {sh 'echo "building on $(hostname)"'sh 'mvn -B package'}}}post {always {echo "ran on node: ${env.NODE_NAME}"}}}
Started by user Sachin[Pipeline] Start of Pipeline[Pipeline] nodeRunning on linux-1 in /home/jenkins/agent/workspace/webapp[Pipeline] {[Pipeline] stage[Pipeline] { (Build)[Pipeline] sh+ echo building on linux-1building on linux-1[Pipeline] sh+ mvn -B package[INFO] BUILD SUCCESS[Pipeline] }[Pipeline] // stage[Pipeline] stage[Pipeline] { (Declarative: Post Actions)[Pipeline] echoran on node: linux-1[Pipeline] }[Pipeline] // stage[Pipeline] }[Pipeline] // node[Pipeline] End of PipelineFinished: 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.
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.