Azure WebApp running a docker container from your private docker hub repository
Microsoft Azure Cloud app services allows customers to run web applications which can be deployed using docker containers residing in Azure Container Registry, Docker Hub (public or private) and other custom docker repositories.
Just like other big-cloud vendors Microsoft Azure offers a limited time free plan and an always free tier within certain limitations. You can find list of free service offers and information about limits at this azure portal free services page
. You can find source code for this article on github here
Synopsis and prerequisites
I wanted to create a gofiber webapp, running inside of a docker from scratch container, deploy and run it using azure app services and secure it using azure active directory authentication provider goth/azureadv2 . Prerequisites to do this are:
- Go compiler: https://golang.org/dl/
- Docker and a free docker hub account to store my containers: https://hub.docker.com
- Microsoft Azure Cloud account: https://azure.microsoft.com free tier would suffice.
End result will be a simple web page that only your default Azure AD users can login:
Go webapp with gofiber and goth/azureadv2
Go code for this web app fairly simple. I was getting “Request Header Fields Too Large” error with default gofiber config. Increasing ReadBufferSize to 16K in fiber config constructor fixed this issue.
Also goth/azureadv2 provider has a known bug getting authenticated users email address info so I used a little workaround for it. You can find full source code on github here
.
func main() {
var testparam string
flag.Parse()
if len(strings.TrimSpace(*flagkey)) > 1 {
testparam = strings.TrimSpace(*flagkey)
}
options := azureadv2.ProviderOptions{
Scopes: []azureadv2.ScopeType{azureadv2.UserReadScope},
Tenant: azureadv2.TenantType(tenantID),
}
goth.UseProviders(
azureadv2.New(applicationID, secret, redirectUri, options),
)
app := fiber.New(fiber.Config{
ReadBufferSize: 16384,
})
app.Get("/auth/:provider/callback", func(ctx *fiber.Ctx) error {
user, err := gf.CompleteUserAuth(ctx)
if err != nil {
return err
}
//PROVIDER-BUG:https://github.com/markbates/goth/issues/289
var userPrincipalName string
for k, v := range dumpMap(user.RawData) {
if k == "userPrincipalName" {
userPrincipalName = v
}
}
var b strings.Builder
b.WriteString(htmlheadsrc)
b.WriteString(`<body><ul><li><a href="/logout/azureadv2">Logout</a></li></ul><br/><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>`)
if len(user.Email) < 2 {
b.WriteString(`<tr><td nowrap>Email</td><td>` + userPrincipalName + `</td></tr>`)
} else {
b.WriteString(`<tr><td nowrap>Email</td><td>` + user.Email + `</td></tr>`)
}
b.WriteString(`<tr><td nowrap>Name</td><td>` + user.Name + `</td></tr>`)
b.WriteString(`<tr><td nowrap>FirstName</td><td>` + user.FirstName + `</td></tr>`)
b.WriteString(`<tr><td nowrap>LastName</td><td>` + user.LastName + `</td></tr>`)
b.WriteString(`<tr><td nowrap>NickName</td><td>` + user.NickName + `</td></tr>`)
b.WriteString(`<tr><td nowrap>Description</td><td>` + user.Description + `</td></tr>`)
b.WriteString(`<tr><td nowrap>UserID</td><td>` + user.UserID + `</td></tr>`)
b.WriteString(`<tr><td nowrap>AvatarURL</td><td>` + user.AvatarURL + `</td></tr>`)
b.WriteString(`<tr><td nowrap>Location</td><td>` + user.Location + `</td></tr>`)
b.WriteString(`<tr><td nowrap>AccessToken</td><td>` + user.AccessToken + `</td></tr>`)
b.WriteString(`<tr><td nowrap>AccessTokenSecret</td><td>` + user.AccessTokenSecret + `</td></tr>`)
b.WriteString(`<tr><td nowrap>RefreshToken</td><td>` + user.RefreshToken + `</td></tr>`)
b.WriteString(`<tr><td nowrap>ExpiresAt</td><td>` + user.ExpiresAt.String() + `</td></tr>`)
b.WriteString(`<tr><td nowrap>IDToken</td><td>` + user.IDToken + `</td></tr>`)
b.WriteString(`<tr><td nowrap>userPrincipalName</td><td>` + user.RawData["userPrincipalName"].(string) + `</td></tr>`)
b.WriteString(`</tbody></table><br/>`)
b.WriteString(`<p>` + os.Getenv("WEBSITE_HOSTNAME") + `</p>`)
b.WriteString(`<p>` + testparam + `</p>`)
b.WriteString(`</body></html>`)
ctx.Set("Content-Type", "text/html")
ctx.Send([]byte(b.String()))
return nil
})
app.Get("/logout/:provider", func(ctx *fiber.Ctx) error {
gf.Logout(ctx)
ctx.Redirect("/")
return nil
})
app.Get("/auth/:provider", func(ctx *fiber.Ctx) error {
if authUser, err := gf.CompleteUserAuth(ctx); err == nil {
ctx.JSON(authUser)
} else {
gf.BeginAuthHandler(ctx)
}
return nil
})
app.Get("/favicon.ico", func(ctx *fiber.Ctx) error {
buf := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x16, 0x08, 0x03, 0x00, 0x00, 0x00, 0xF7, 0x9F, 0x4C, 0x34, 0x00, 0x00, 0x00, 0x12, 0x50, 0x4C, 0x54, 0x45, 0xFF, 0xFF, 0xFF, 0xCC, 0xFF, 0xFF, 0x99, 0x99, 0x99, 0x66, 0x66, 0x66, 0x33, 0x33, 0x33, 0x00, 0x00, 0x00, 0x9E, 0x8B, 0x9A, 0xE7, 0x00, 0x00, 0x00, 0x02, 0x74, 0x52, 0x4E, 0x53, 0xFF, 0x00, 0xE5, 0xB7, 0x30, 0x4A, 0x00, 0x00, 0x00, 0x3D, 0x49, 0x44, 0x41, 0x54, 0x78, 0xDA, 0xCD, 0xD0, 0x31, 0x0E, 0x00, 0x20, 0x0C, 0x02, 0x40, 0xA0, 0xF2, 0xFF, 0x2F, 0x8B, 0x23, 0x89, 0xBB, 0x76, 0xB9, 0x00, 0x5B, 0xC1, 0xCB, 0x3D, 0x2F, 0xED, 0x58, 0x80, 0x96, 0x62, 0x01, 0x0B, 0x88, 0x05, 0xC2, 0x19, 0x8B, 0x94, 0xD2, 0x98, 0x05, 0x96, 0x67, 0xBC, 0x58, 0x20, 0x26, 0xB0, 0x40, 0x3C, 0xA1, 0xF8, 0xF0, 0x75, 0x1B, 0x75, 0xA3, 0x02, 0xBE, 0x47, 0x80, 0xD3, 0xF4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82}
ctx.Set("Content-Type", "image/x-icon")
ctx.Send(buf)
return nil
})
app.Get("/", func(ctx *fiber.Ctx) error {
var b strings.Builder
b.WriteString(htmlheadsrc)
b.WriteString(`<body><ul><li><a href="/auth/azureadv2">Login</a></li><li><a>Logout</a></li></ul>`)
b.WriteString(`<p>` + os.Getenv("WEBSITE_HOSTNAME") + `</p>`)
b.WriteString(`<p>` + testparam + `</p>`)
b.WriteString(`</body></html>`)
ctx.Set("Content-Type", "text/html")
ctx.Send([]byte(b.String()))
return nil
})
log.Fatal(app.Listen(":80"))
}
Dockerfile and preparing container
Because we are using a scratch container we need to provide ssl certificates manually otherwise authentication will fail. To do this we use a docker multi stage build:
FROM alpine:latest as certs
RUN apk --update add ca-certificates
# https://medium.com/on-docker/use-multi-stage-builds-to-inject-ca-certs-ad1e8f01de1b
FROM scratch
EXPOSE 80
ENV PATH=/bin
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY ./tweb1 /
CMD ["/tweb1"]
Deploy container to Azure Cloud using Portal
At this stage we should have our app container inside our docker hub private repo. I think how to use docker and docker hub is out of scope of this post so I assume we have built our container and pushed it to our private hub repository already.
Creating a web app at Azure Portal using web based UI is very easy. This is just a wizard type UI that you fill out some required values and click next to continue on next step until final page where you review all info and create the webapp. After that we need to configure authentication and create Azure AD app registration for our webapp. Here steps how to to so:
- Login to Azure Portal and create a new webapp in app services.
- Bear in mind free-tier may not be available in some regions, you can change the region if necessary.
- Choose docker hub for deployment source and fill out your docker credentials.
- After creating webapp, go to authentication section and click to add identity provider.
- This step will create active directory app registration and create access token secret for our app.
- It is important to allow anonymous access to users so they can click login button to start the authentication process.
- Go to webapp configuration section and create tree new values for:
- APPLICATION_ID, AD_TENANT_ID, REDIRECT_URI.
- These are necessary values to authenticate users with Azure AD provider.
- You should also see settings value for MICROSOFT_PROVIDER_AUTHENTICATION_SECRET already created for us when we added identity provider in previous step.
Deploy using Azure CLI:
Azure CLI is very powerful tool to manage azure resources. We can use it to automate deployment with CI/CD pipelines. I think the easiest way to run azure cli is using docker. Run it with the command: ~$ docker run –rm -it mcr.microsoft.com/azure-cli
This script below shows how to use cli to create webapp with container, configure environment and set up authentication:
# https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
# https://docs.microsoft.com/en-us/cli/azure/run-azure-cli-docker
# Using Docker: docker run -it mcr.microsoft.com/azure-cli
# ~$ sudo docker run --rm -it mcr.microsoft.com/azure-cli
# Unable to find image 'mcr.microsoft.com/azure-cli:latest' locally
# latest: Pulling from azure-cli
# 540db60ca938: Pull complete
# a7ad1a75a999: Pull complete
# 8d9a9d314bf5: Pull complete
# Digest: sha256:2090963629cc9c595bbad24354bb6879112d8900000000000009
# Status: Downloaded newer image for mcr.microsoft.com/azure-cli:latest
# bash-5.1# az
#
# Welcome to Azure CLI!
# ---------------------
# Use `az -h` to see available commands or go to https://aka.ms/cli.
#
# Telemetry
# ---------
# The Azure CLI collects usage data in order to improve your experience.
# The data is anonymous and does not include commandline argument values.
# The data is collected by Microsoft.
#
# You can change your telemetry settings with `az configure`.
#
#
# /\
# / \ _____ _ _ ___ _
# / /\ \ |_ / | | | \'__/ _\
# / ____ \ / /| |_| | | | __/
# /_/ \_\/___|\__,_|_| \___|
#
#
# Welcome to the cool new Azure CLI!
#
# this optional step, if you have more than one subscription,
# you should set the default subscription,
# which will be used for the remaining Azure CLI commands.
# ~$ az account set --subscription XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
#
export tenantId='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'
export webAppName='tweb1app'
export resGroup='tweb1-resgrp'
export location='northeurope'
export servicePlan='tweb1-freeplan'
export tier='F1'
export containerImg='DOCKER|dockeruser/myrepo:tweb1'
export dockerUsr='dockeruser'
export dockerPass='xxxxxxxxxxx'
#
az login --tenant $tenantId
# bash-5.1# az login --tenant $tenantId
# Not able to launch a browser to log you in, falling back to device code...
# To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code DA5EFUFVL to authenticate.
#
# create new resource group
az group create --name $resGroup --location $location
# create new app service plan Free tier (sku:F1)
az appservice plan create -n $servicePlan -g $resGroup --sku $tier --is-linux
# create new web app (Note: as of 2021-08-28 this command can not configure private-docker-hub-conatiner image deployments so after this command you need to issue another config-container-set command to reach desired configuration.)
az webapp create -n $webAppName -g $resGroup -p $servicePlan -i $containerImg -s $dockerUsr -w $dockerPass --tags Lifecycle=Test
az webapp stop --name $webAppName --resource-group $resGroup
#
az webapp config container set --name $webAppName --resource-group $resGroup --docker-custom-image-name 'DOCKER|dockeruser/myrepo:tweb1' --docker-registry-server-url 'https://index.docker.io/v1' --docker-registry-server-user 'dockeruser' --docker-registry-server-password 'xxxxxxxxxxx'
#
az ad app create --display-name $webAppName --available-to-other-tenants false --key-type Password --password '111xxxxxxxxxxxx11_1111xxxx1x' --reply-urls 'https://tweb1app.azurewebsites.net/auth/azureadv2/callback'
#
az webapp stop --name $webAppName --resource-group $resGroup
# a custom startup command for docker container
az webapp config set --resource-group $resGroup --name $webAppName --startup-file '/tweb1 -k sample_param-value,1714'
# configure web app settings, get the value for APPLICATION_ID which you can find inside of resulted json output from `az ad create` command like: "appId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
az webapp config appsettings set --resource-group $resGroup --name $webAppName --settings APPLICATION_ID='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'
az webapp config appsettings set --resource-group $resGroup --name $webAppName --settings AD_TENANT_ID=$tenantId
az webapp config appsettings set --resource-group $resGroup --name $webAppName --settings REDIRECT_URI='https://tweb1app.azurewebsites.net/auth/azureadv2/callback'
az webapp config appsettings set --resource-group $resGroup --name $webAppName --settings MICROSOFT_PROVIDER_AUTHENTICATION_SECRET='111xxxxxxxxxxxx11_1111xxxx1x'
#
az webapp start --name $webAppName --resource-group $resGroup