The hybrid cloud isn’t just a buzzword—it’s the reality for most organizations. While public cloud offers flexibility and global reach, some workloads need to stay on-premises for compliance, latency, or cost reasons. With DevOps, we can deploy our applications on either environment. On Azure, the deployment targets function apps, static web sites, app services, or other service. On Azure Local we’re using a simple docker host instead.
This post describes a recent demo where I showed how the same application / code base can easily be deployed globally on Azure and locally on Azure Local.
Most enterprises operate in a hybrid world. You might have:
Traditionally, on-premises and cloud are treated as separate worlds with different tools, different processes, different expertise. This creates friction, slows delivery, and increases the operational burden on teams.
I wanted to deploy the same code I was using for my demo on Azure but Azure Local doesn’t provide Function Apps or Static Web Apps (yet). Could I use the same DevOps practices everywhere and just deploy to Docker instead of those Azure services? The same pipelines, the same container workflows, the same deployment patterns—just executing in different locations based on workload requirements. The answer is yes.
This is exactly what Azure Local enables. By running Azure’s infrastructure capabilities on your own hardware, you get a consistent management plane across cloud and on-premises. Combined with self-hosted Azure DevOps agents and Docker, you can build a deployment model that feels like cloud but runs locally.
My sample application is a static web site with a nodejs backend for some server-side features like role verification and search. On Azure it runs as a Static Web App with a managed backend (Function App).
Azure Local supports VM and Kubernetes workloads natively. While Kubernetes is powerful, it comes with significant complexity which I’m happy to avoid for this example.
For single-node or small-scale deployments, Docker Compose provides everything we need:
┌─────────────────────────────────────────────────────────────┐
│ Ubuntu VM with Docker on Azure Local │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Frontend │◄──►│ API │◄──►│ Database │ │
│ │ Container │ │ Container │ │ Container │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Self-Hosted Agent │ │
│ │ (Azure DevOps) │ │
│ └─────────┬─────────┘ │
└──────────────────────────────┼──────────────────────────────┘
│
┌────────▼────────┐
│ Azure DevOps │
│ Pipeline │
└─────────────────┘
To make it even simpler, I have the self-hosted agent running directly on my Docker host. This eliminates the need for container registries and works just fine for a demo or local-first setup. Images are built and run on the same machine making deployments fast, and keeping data secure.
Microsoft provide the base image that Function Apps use in the public Microsoft Container Registry, all I had to do was use a simple Dockerfile to build an image that can then be run on my local Docker Host.
# Use the Azure Functions base image for Node.js
FROM mcr.microsoft.com/azure-functions/node:4-node20
# Set working directory
WORKDIR /home/site/wwwroot
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy function app files
COPY . .
# Expose the default Azure Functions port
EXPOSE 80
Running docker compose build will download the function app image, install dependencies, and finally copy my backend code into the image. This can then be run on any Docker host with docker run or be part of an application with docker compose.
The Static Web App service is taking care of the build process on Azure (thanks to Oryx). For my local deployment, I made another very simple Dockerfile, that builds the app, and serves it with nginx.
# Stage 1: Build Jekyll site
FROM ruby:3.2-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
build-base \
git \
nodejs \
npm
# Set working directory
WORKDIR /site
# Copy Gemfile and install dependencies
COPY frontend/Gemfile* ./
RUN bundle update
RUN bundle install
# Copy the rest of the site and build
COPY frontend/ ./
RUN bundle exec jekyll build
# Stage 2: Serve with nginx
FROM nginx:alpine
# Copy built site from builder stage
COPY --from=builder /site/_site /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Docker Compose enables the definition of multi-container applications declaratively:
services:
frontend:
build: ./frontend
ports:
- "8080:80"
networks:
- app-network
api:
build: ./api
ports:
- "7071:80"
networks:
- app-network
networks:
app-network:
driver: bridge
This single file captures my entire application topology. The same definition works in development, CI/CD, and production.
The deployment approach prioritizes reliability over complexity:
docker compose downdocker compose up -dThe Azure DevOps pipeline reflects this simplicity:
trigger:
branches:
include:
- main
pool: azlocal # Self-hosted agent pool
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: docker compose build
displayName: 'Build Images'
- stage: Deploy
dependsOn: Build
jobs:
- deployment: DeployJob
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: docker compose down && docker compose up -d
displayName: 'Deploy'