Using your VSCode dev container as a hosted Azure DevOps build agent

Devcontainers are awesome for keeping tooling consistent over the team, so what about when you need to run your build?

There is some great work already done talking about how to use these as part of a normal pipeline (shout out to Eliise!), what about if you need your build agent to be inside a virtual network in Azure?

The standard approach would be to create a VM, setup tools and join that as an Agent to Azure Devops.

As we’ve already got a definition of the tooling we need, our devcontainer, can we reuse that to simplify things?

Turns out we can, using an Azure Container Repository, Azure Container Instance and a few tweaks to our devcontainer we can spin up an agent for Devops based on the devcontainer and start using it.

To do this we need to:

  1. Add the AzureDevops Agent script to your devcontainer
  2. Build the image and push up to your Azure Container Repository following this guide
  3. Use Terraform to deploy the built container into an Azure Container Instance

The snippets below assume you already have your agent built and pushed up to your Azure Container Repository with the name your_repo_name_here.azurecr.io/devcontainer:buildagent.

It shows the .Dockerfile for the devcontainer the bash script to start a devcontainer (slight edit from doc here) and the terraform to deploy it into a VNET.

You’ll have to do some tweaks, best to treat this as a starting point. See this doc for more detailed docs on how this work.

# Very basic devcontainer, see line 15 copying in build agent start script
# https://github.com/Azure/azure-functions-docker/blob/master/host/3.0/buster/amd64/dotnet/dotnet-core-tools.Dockerfile
FROM mcr.microsoft.com/azure-functions/dotnet:3.0-dotnet3-core-tools
# To make it easier for build and release pipelines to run apt-get,
# configure apt to not require confirmation (assume the -y argument by default)
ENV DEBIAN_FRONTEND=noninteractive
RUN echo "APT::Get::Assume-Yes \"true\";" > /etc/apt/apt.conf.d/90assumeyes
# Install system tools
RUN apt-get update \
&& apt-get -y install –no-install-recommends apt-utils nano unzip curl icu-devtools bash-completion jq
# Add AzureDevops build agent script
COPY ./buildagentstart.sh .
view raw .Dockerfile hosted with ❤ by GitHub
set -e
# This script comes from the following documentation
# See https://docs.microsoft.com/en-us/azure/devops/pipelines/agents/docker?view=azure-devops
if [ -z "$AZP_URL" ]; then
echo 1>&2 "error: missing AZP_URL environment variable"
exit 1
if [ -z "$AZP_TOKEN_FILE" ]; then
if [ -z "$AZP_TOKEN" ]; then
echo 1>&2 "error: missing AZP_TOKEN environment variable"
exit 1
mkdir -p /azp/
if [ -n "$AZP_WORK" ]; then
mkdir -p "$AZP_WORK"
rm -rf /azp/agent
mkdir /azp/agent
cd /azp/agent
cleanup() {
if [ -e config.sh ]; then
print_header "Cleanup. Removing Azure Pipelines agent…"
./config.sh remove –unattended \
–auth PAT \
–token $(cat "$AZP_TOKEN_FILE")
print_header() {
echo -e "${lightcyan}$1${nocolor}"
# Let the agent ignore the token env variables
print_header "1. Determining matching Azure Pipelines agent…"
-u user:$(cat "$AZP_TOKEN_FILE") \
-H 'Accept:application/json;api-version=3.0-preview' \
if echo "$AZP_AGENT_RESPONSE" | jq . >/dev/null 2>&1; then
| jq -r '.value | map([.version.major,.version.minor,.version.patch,.downloadUrl]) | sort | .[length-1] | .[3]')
if [ -z "$AZP_AGENTPACKAGE_URL" -o "$AZP_AGENTPACKAGE_URL" == "null" ]; then
echo 1>&2 "error: could not determine a matching Azure Pipelines agent – check that account '$AZP_URL' is correct and the token is valid for that account"
exit 1
print_header "2. Downloading and installing Azure Pipelines agent…"
curl -LsS $AZP_AGENTPACKAGE_URL | tar -xz & wait $!
source ./env.sh
print_header "3. Configuring Azure Pipelines agent…"
./config.sh –unattended \
–agent "${AZP_AGENT_NAME:-$(hostname)}" \
–url "$AZP_URL" \
–auth PAT \
–token $(cat "$AZP_TOKEN_FILE") \
–pool "${AZP_POOL:-Default}" \
–work "${AZP_WORK:-_work}" \
–replace \
–acceptTeeEula & wait $!
print_header "4. Running Azure Pipelines agent…"
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
# To be aware of TERM and INT signals call run.sh
# Running it with the –once flag at the end will shut down the agent after the build is executed
./run.sh & wait $!
variable azp_docker_image {
description = "The docker image to use when running the build agent. This defaults to a build of ./.devcontainer pushed to the ACR container"
type = string
default = "your_repo_name_here.azurecr.io/devcontainer:buildagent"
variable azp_token {
description = "The token used for the azure pipelines build agent to connect to Azure Devops"
type = string
default = ""
variable azp_url {
description = "The url of the Azure Devops instance for the agent to connect to eg: https://dev.azure.com/yourOrg"
type = string
default = "https://dev.azure.com/your_org_here"
variable "docker_registry_username" {
description = "Docker registry to be used for containers"
default = "your_repo_name_here"
variable "docker_registry_password" {
description = "Docker registry password"
variable "subnet_id" {
description = "Azure subnet ID the build agent should be deployed onto"
variable "docker_registry_url" {
description = "Docker registry url"
default = "your_repo_here.azurecr.io"
resource "azurerm_resource_group" "env" {
location = var.resource_group_location
name = var.resource_group_name
tags = var.tags
resource "azurerm_network_profile" "buildagent" {
name = "acg-profile"
location = azurerm_resource_group.env.location
resource_group_name = azurerm_resource_group.env.name
container_network_interface {
name = "acg-nic"
ip_configuration {
name = "aciipconfig"
subnet_id = var.subnet_id
resource "azurerm_container_group" "build_agent" {
name = "buildagent"
location = azurerm_resource_group.env.location
resource_group_name = azurerm_resource_group.env.name
tags = var.tags
network_profile_id = azurerm_network_profile.buildagent.id
ip_address_type = "Private"
os_type = "Linux"
image_registry_credential {
username = var.docker_registry_username
password = var.docker_registry_password
server = var.docker_registry_url
container {
name = "buildagent"
image = var.azp_docker_image
cpu = "1"
memory = "2"
commands = ["bash", "-f", "./buildagentstart.sh"]
ports {
port = 443
protocol = "TCP"
environment_variables = {
// The URL of the Azure DevOps or Azure DevOps Server instance.
AZP_URL = var.azp_url
// Personal Access Token (PAT) with Agent Pools (read, manage) scope, created by a user who has permission to configure agents, at AZP_URL.
AZP_TOKEN = var.azp_token
// Agent name (default value: the container hostname).
AZP_AGENT_NAME = local.shared_env.rg.name
// Agent pool name (default value: Default).
AZP_POOL = local.shared_env.rg.name
// Work directory (default value: _work).
AZP_WORK = "_work"
view raw main.tf hosted with ❤ by GitHub

Dev Keyboard: Moon Lander Mark 1 Review 😍

I’ve had pain in my right wrist from computer usage for a while now, last 5 years or so. To fix it I’ve done lots of stuff, like:

  • Move to using mouse left handed
  • Move to roller ball left handed mouse
  • Move to ergonomic keyboard
  • Move to fully split keyboard

In the arms race that is keeping my wrists working I decided to go for the next step and get a (expensive) programmable and columnar and see if it helped.

So I picked up the Moonlander Mark1 to give it a try: https://www.zsa.io/moonlander/

What is a columnar keyboard?

Instead of each row of keys being offset from each other each set of keys is directly above and below in a column. The logic being it’s easier to move your fingers up and down than side to side.


This took some adjustment, frequently I’d miss-type X/C/V but I do now find myself more comfortable when typing on the keyboard and find it notably less comfortable returning to a normal keyboard.

What makes it programmable?

This means I can change the layout, create macros and setup different layers.

For example, moving my right hand from the home keys to use the arrow keys on a normal keyboard causes strain. With the Moonlander I can create a “layer” which I toggle with a keypress and while toggled my home row become my arrow keys.

You can see my current layout here on the configurator site: https://configure.ergodox-ez.com/moonlander/layouts/ZrGNr/latest/0

So how did it go?

First up, getting used to the keyboard took me a good few weeks. Initially I tried to get used to the default layout which didn’t go well but when as I embraced programming it things went much smoother.

Within about 3 weeks my typing was back up to normal with slightly higher error count.

At this point though I wasn’t really getting the most out of the keyboard, month 2 and 3 was when I really fell in love.

I started to remap/reprogram it A LOT moving my commonly used keys to be close to my home row. During a normal day I’d keep track of how I used shortcuts, keys and punctuation and then work out how to move things around to make them easier to type. I stayed with QWERY for fear of not being able to type on a normal keyboard but outside that everything was fair game – backspace moved, enter moved, CTRL etc etc you get the point.

As an example of this which worked well for me as a programmer, under my left hands home I created a layer with all the punctuation I use when programming, no more shift and reaching to the top keyboard row. Now for a curl brace I tap my right little finger then E key.

This has been so nice!

Next up is the thumb bar, there are 3 keys under each. This means those under used digits which normally just mashing space can now do seriously useful stuff. Under my left thumb I have Enter, Delete and Super for example. The move of Backspace to under my left thumb had a drastic impact for me, I think reducing the reach for the backspace top right with my hand massively helped my wrist pain.

I’ve always tried to minimize my usage of the mouse for productivity, being able to map custom layers means things like Regoliths i3 based linux setup can easily be mapped via macros and layers to support single key press shortcuts to manage windows and the system. Where in the past I’ve bounced off i3 based systems I found armed with macros and layers on the keyboard they clicked so easily.


It’s been a hard and long journey, having to actively think about typing and what keys do what again hurt as it’s been second nature for so long BUT it’s been worth it.

Now I frequently find myself optimising a key/punctuation or combination I use regularly to reduce the movement used when typing it and it pays dividends every time.

If you like tweaking your workflow and spend you time writing code I’d highly recommend it as an investment.

#terraform, Apps, Azure, Coding, How to

Azure Functions Get Key from Terraform without InternalServerError

So you’re trying to use the Terraform azurerm_function_app_host_keys resource to get the keys from an Azure function after deployment. Sadly, as of 03/2021, this can fail intermittently 😢 (See issue 1 and 2).+

[Edit: Hopefully this issue is resolved by this PR once released so worth reviewing once the change is released]

These errors can look something like these below:

Error making Read request on AzureRM Function App Hostkeys “***”: web.AppsClient#ListHostKeys: Failure responding to request: StatusCode=400 — Original Error: autorest/azure: Service returned an error. Status=400 Code=”BadRequest” Message=”Encountered an error (ServiceUnavailable) from host runtime”

Error: Error making Read request on AzureRM Function App Hostkeys “somefunx”: web.AppsClient#ListHostKeys: Failure responding to request: StatusCode=400

You can work around this by using my previous workaround with ARM templates but it’s a bit clunky so I was looking at another way to do it.

There is an AWESOME project by Scott Winkler called Shell Provider, it lets you write a custom Terraform provider using scripts. You can implement data types and full resources with CRUD support.

Looking into the errors returned by the azurerm_function_app_host_keys resource they’re intermittent and look like they’re related to a timing issue. Did you know the curl command support retrying out of the box?

So using the Shell provider we can create a simple script to make the REST request to the Azure API and use curls inbuilt retry support to have the request retried with an exponential back-off until it succeeds or 5mins is up!

Warning: This script uses –retry-all-errors which is only available in v7.71 and above. The version shipped with the distro your using might not be up-to-date user curl --version to check.

Here is a rough example of what you end up with:

terraform {
required_providers {
shell = {
source = "scottwinkler/shell"
version = "1.7.7"
resource "azurerm_function_app" "functions" {
name = "${var.function_name}${var.random_string}-premium"
location = var.resource_group_location
resource_group_name = var.resource_group_name
app_service_plan_id = var.app_service_plan_id
version = "~3"
storage_account_name = var.storage_account_name
storage_account_access_key = var.storage_account_key
identity {
type = "SystemAssigned"
site_config {
# Ensure we use all the mem on the box and not only 3.5GB of it!
use_32_bit_worker_process = false
pre_warmed_instance_count = 1
app_settings = merge({
StorageContainerName = var.test_storage_container_name
https_only = true
HASH = base64encode(filesha256(local.func_zip_path))
WEBSITE_RUN_FROM_PACKAGE = "https://${var.storage_account_name}.blob.core.windows.net/${var.deployment_container_name}/${azurerm_storage_blob.appcode.name}${var.storage_sas}"
# Route outbound requests over VNET see: https://docs.microsoft.com/en-us/azure/azure-functions/functions-networking-options#regional-virtual-network-integration
}, var.app_settings)
data "azurerm_subscription" "current" {
data "shell_script" "functions_key" {
lifecycle_commands {
read = file("${path.module}/readkey.sh")
environment = {
FUNC_NAME = azurerm_function_app.functions.name
RG_NAME = var.resource_group_name
SUB_ID = data.azurerm_subscription.current.subscription_id
depends_on = [azurerm_function_app.functions]
view raw main.tf hosted with ❤ by GitHub
output "function_master_key" {
# Try is used here to ensure destroy works as expected. On destroy the map will be
# empty so try instead returns an empty string
# See: https://www.terraform.io/docs/language/functions/try.html
value = try(data.shell_script.functions_key.output["masterKey"], "")
output "function_hostname" {
value = azurerm_function_app.functions.default_hostname
output "function_name" {
value = azurerm_function_app.functions.name
view raw output.tf hosted with ❤ by GitHub
set -e
# Get a token so we can call the ARM api
TOKEN=$(az account get-access-token -o json | jq -r .accessToken)
# Attempt to list the keys with exponential backoff and do this for 5mins max
# –fail required see https://github.com/curl/curl/issues/6712
curl "https://management.azure.com/subscriptions/$SUB_ID/resourceGroups/$RG_NAME/providers/Microsoft.Web/sites/$FUNC_NAME/host/default/listkeys?api-version=2018-11-01" \
–compressed -H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: Bearer $TOKEN" -d "{}" \
–retry 8 –retry-max-time 360 –retry-all-errors –fail –silent
view raw readkeys.sh hosted with ❤ by GitHub