DEV Community

Cover image for Day 77: A Pipeline and a Private Subnet
Nnamdi Felix Ibe
Nnamdi Felix Ibe

Posted on AI-assisted

Day 77: A Pipeline and a Private Subnet

Day 77 of DevOps, Day 27 of Azure. Both tasks today were about a path that exists until it doesn't.

A two-line shell script deployed an application and quietly depended on three things nobody had checked. And an Azure VM built with no public IP turned out to be the exact configuration that the platform is in the middle of changing underneath.

One Jenkins task, one Azure task. Write a first declarative pipeline, then build a VM reachable only from inside its own network. The tasks come from the KodeKloud Engineer platform.

Task 1: A first declarative pipeline

The task: a Pipeline job named datacenter-webapp-job that pulls the latest code from master onto stapp01.

Step 1: The agent and a stale instruction

Plugins:  SSH Build Agents, Pipeline
Agent:    App Server 1, label stapp01, remote root /home/sarah/jenkins_agent
Enter fullscreen mode Exit fullscreen mode

In every Jenkins task in this batch, I had to install Java 17 on the node, "as Jenkins (or its remoting agent) is compiled for Java 17".

The shape of that is right, and the version is two baselines stale. Jenkins' Java requirement is a project decision with dates on it, not a property of a Java release. It became 17 at the 2.463 weekly release in June 2024, covering "agent JVMs (i.e., JVMs running remoting.jar)" explicitly. And it has moved again since: "Beginning with Jenkins 2.555.1, Jenkins requires Java 21 or Java 25 on both the controller JVM and agent JVMs", with the Java 17 images retired.

So Java 17 is correct for this lab, which runs 2.541.3, the last LTS supporting it. As a general rule, it is already out of date. Match the controller's requirement, and upgrade the controller and agents together.

Day 75 covered what this prevents, and my notes undersell it by framing it as a compilation detail: "Running a Java 11 agent with a Java 17 controller produces an UnsupportedClassVersionError." The agent does not start at all.

Step 2: The pipeline

pipeline {
    agent { label 'stapp01' }

    stages {
        stage('Deploy') {
            steps {
                sh '''
                cd /var/www/html
                git pull origin master
                '''
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

agent { label 'stapp01' } is documented plainly: it will "Execute the Pipeline, or stage, on an agent available in the Jenkins environment with the provided label." Label expressions work too, so 'my-label1 && my-label2' is valid once more than one agent shares labels.

The thing worth noticing is that the job names a label, not a machine. That is the payoff from Day 75's agent work: pointing this deployment at a different server is a label change, not a job rewrite.

And the sh '''...''' uses triple single quotes. Nothing in this script references a variable, so today it makes no difference whatsoever. Tomorrow it makes a large one.

What the docs recommend

Two lines of shell, and three assumptions underneath them.

That /var/www/html is already a git working copy with an origin remote. On a fresh server, it is a document root and nothing else, and git pull there fails with a message about not being a git repository, which reads like a path typo.

That the agent's user can write to it, this pipeline runs as sarah, and it works only if sarah owns the directory or has write access. Worth watching across this batch, because three days answer this one question three different ways: today leaves it implicit, Day 79 checks the ownership explicitly, and Day 80 reaches for sudo with a password in the build step.

That a pull is the right operation at all. This is the one I would change. git pull merges. A local edit in the document root produces a merge instead of a deployment, and a conflict leaves the web root half-merged with conflict markers in the files Apache is serving.

git fetch origin
git reset --hard origin/master
Enter fullscreen mode Exit fullscreen mode

That is the deployment-shaped operation, because it makes the working tree match the remote regardless of what was there before. A deployment should not care about local state. A pull does, by design.

Task 2: A VM reachable only from inside

The task: Day 26 inverted. Same resource shapes, opposite reachability, with SSH permitted only from the VNet's own CIDR block.

Step 1: One flag decides the whole task

--nsg-rule NONE
Enter fullscreen mode Exit fullscreen mode

--nsg-rule accepts {NONE, RDP, SSH}, and for a Linux VM it defaults to SSH, which creates default-allow-ssh with source *. That permits SSH from anywhere on the internet, which is the direct opposite of what this task asks for.

Day 26 left the default in place and was right to. Same command, opposite requirement, one flag, and it is easy not to think about precisely because it is the default.

So the verification has to list everything:

Name            Pri   Dir      Access  Proto  Src          Dst          Port
Allow-SSH-VNet  100   Inbound  Allow   Tcp    10.0.0.0/16  10.0.0.0/16  22
Enter fullscreen mode Exit fullscreen mode

One row. A default-allow-ssh row sitting next to it would mean the VM was internet-reachable while every other check still looked fine, and the NSG would read as "scoped to the VNet" at a glance.

When the requirement is about what is not permitted, confirming the rule you added proves nothing. You have to enumerate what exists.

Step 2: Two layers, and only one of them is about inbound

"PubIP": "",  "PrivIP": "10.0.1.4"
Enter fullscreen mode Exit fullscreen mode

--public-ip-address "" means no internet-routable address, so there is no inbound path. The NSG then restricts source even inside the VNet. Either alone would do it; both together survive a single mistake, because adding a public IP later would not open SSH and loosening the NSG would not either.

That much I had. What I had missed is that the absence of a public IP is also the condition that a platform change is now acting on, and it affects what this VM can do rather than what can reach it.

Step 3: This is the VM that loses its way out

How does a VM with no public IP reach the package mirrors at all? Microsoft: "when you deploy a virtual machine (VM) in a virtual network without an explicitly defined outbound connectivity method, Azure automatically assigns it an outbound public IP address."

That address is not yours. "This IP, known as the default outbound access IP, is owned by Microsoft and can change without notice", and "default outbound connectivity relies on an implicit platform behaviour and may be affected by platform-level changes."

And it is going away for new subnets. "Creating a subnet to be Private prevents any virtual machines on the subnet from utilising default outbound access to connect to public endpoints." Day 76 recorded the date, which has passed: subnets in new virtual networks now default to defaultOutboundAccess set to false.

So a VM built to exactly this specification, in a new virtual network today, has no inbound path and no outbound path.

The four explicit outbound methods are a NAT gateway on the subnet, an outbound-rule load balancer, a Standard public IP on the NIC, and a firewall or appliance behind a user-defined route. Day 26's VM had the third one by accident: the public IP that made it reachable also made its egress explicit. This VM has none of them, and a NAT gateway is the documented recommendation for most scenarios.

Two exceptions stop a private VM from being completely cut off, and they are worth knowing in opposite directions. "Virtual machines are still able to access Azure Storage accounts in the same region in a private subnet without an explicit method of outbound." But "Certain services don't function on a virtual machine in a private subnet without an explicit method of egress (examples are Windows Activation and Windows Updates)."

Microsoft's stated reasons read as an argument rather than a deprecation notice: "Security: Default internet access contradicts Zero Trust principles. Clarity: Explicit connectivity is preferred over implicit access. Stability: The default outbound IP isn't customer-owned and can change."

What the docs recommend

The enforcement here is entirely outside the VM, and it is worth being precise about that, because "SSH accessible from within the VNet only" sounds like host configuration and is not:

LISTEN 0 128 0.0.0.0:22  users:(("sshd",pid=861,fd=3))
Enter fullscreen mode Exit fullscreen mode

sshd is bound to 0.0.0.0, accepting from any address. Nothing in the guest narrows it. Which means an NSG change takes effect immediately with no reboot, and also that an NSG mistake has no backstop: there is no firewall rule or ListenAddress on the host to catch it.

One more thing I did not do, and would rather say than imply. The task says SSH should work from inside the VNet, and I never tested that, because there is no second VM in the VNet to connect from. What I established is that sshd is listening, the NSG permits 22 from 10.0.0.0/16, no other inbound rule exists, and there is no public IP. That makes the conclusion sound, and it is an inference.

The documented way to actually test it is Azure Bastion, which deploys into an AzureBastionSubnet and brokers SSH without putting a public IP on the target. Given Day 38 failed precisely by treating configuration as proof of behaviour, the distinction is worth keeping visible.

What I learned

Both halves were about a path that works until something stops holding it up.

The pipeline deploys correctly, right now, on a server where someone previously made /var/www/html a git working copy and gave sarah write access to it. None of that is in the pipeline. The job is two lines, and the configuration it depends on is somewhere else entirely, undocumented, and discovered only when it is missing.

The Azure VM reaches the internet, right now, through an address Microsoft owns and assigned implicitly because the VM had no other way out. That is being retired, with a date that has already passed for new networks, and Microsoft's own first reason is that implicit internet access contradicts Zero Trust. The VM is not misconfigured. The thing it was resting on is being removed.

So here is the Day 77 question. What does your deployment job assume about the server it deploys to that the job itself does not say?

Day 77 down. Twenty-three to go.

Top comments (0)