How I Deployed My App to Azure with GitHub Actions, Terraform, and NO CREDENTIALS
See how I deployed my application to Microsoft Azure using Terraform and GitHub Actions Without Using ANY Credentials via Federated Identity
Over the weekend, I decided to explore GitHub Actions, Terraform, and how to combine them to deploy a simple application on Microsoft Azure.
The application is a basic movies CRUD app. The front end is a Blazor Web App that communicates with the back end, which is a minimal Web API. For the database, I chose Cosmos DB.
The source code for the application and the terraform scripts is available on GitHub, here.
Here’s the infrastructure I provisioned for the application:
App Service for the ASP.NET Core Web API (the backend).
App Service for the Blazor Web App (the frontend).
Cosmos DB for the NoSQL database.
Key Vault for securely storing secrets.
User Assigned Managed Identity for secure authentication without using credentials.
Why Managed Identity?
There are several options for integrating GitHub Actions with Azure. The easiest approach is to use publish profiles, which involve uploading a .pubxml
file to your GitHub repository. However, this method is not ideal from a security perspective because it relies on static credentials.
Instead, I decided to use a User Assigned Managed Identity (UAMI), which is one of Azure’s recommended approaches for secure service-to-service communication. This approach eliminates the need for storing or managing credentials.
Now, let’s take a look at the terraform scripts that I wrote.
variables.tf
I used a variables.tf
file to define metadata for my resources, such as names and locations. This makes the code reusable and flexible, allowing me to modify these values without changing the core logic of the script.
variable "resource_group_name" {
description = "The name of the resource group in which to create the resources."
type = string
default = "rg-moviescosmos"
}
variable "location" {
description = "The Azure region in which to create the resources."
type = string
default = "West Europe"
}
variable "app_service_plan_name" {
description = "The name of the App Service Plan."
type = string
default = "asp-moviesapp-cosmosdb"
}
variable "web_api_app_name" {
description = "The name of the Web API App Service."
type = string
default = "app-moviesapp-cosmosdb"
}
variable "blazor_web_app_name" {
description = "The name of the Blazor Web App Service."
type = string
default = "app-moviesapp-cosmosdb-blazor"
}
variable "cosmos_db_name" {
description = "The name of the Cosmos DB account."
type = string
default = "moviesappcosmosdb"
}
variable "sku" {
description = "The SKU of the App Service Plan."
type = string
default = "B1" # Free tier for App Service Plan
}
variable "cosmos_db_offer_type" {
description = "The offer type for the Cosmos DB account."
type = string
default = "Standard"
}
variable "subscription_id" {
description = "The Azure subscription ID."
type = string
default = "[ADD YOUR SUBSCRIPTION ID HERE]"
}
variable "keyvault_moviescosmos1" {
description = "The name of the Key Vault."
type = string
default = "kv-moviescosmos01"
}
variable "keyvault_moviescosmos1_dbsecret" {
description = "The name of the Key Vault secret for the Cosmos DB connection string."
type = string
default = "MoviesDbConnection"
}
variable "uami_github" {
description = "The User Assigned Managed Identity for GitHub Actions."
type = string
default = "uami_github"
}
Note: Replace
[ADD YOUR SUBSCRIPTION ID HERE]
with your own subscription if you want to try this on your own.
main.tf
This is the main terraform file that provisions the resources already mentioned above.
# Provider Configuration
provider "azurerm" {
subscription_id = var.subscription_id
features {}
}
data "azurerm_client_config" "current" {}
# Resource Group
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
}
# App Service Plan
resource "azurerm_service_plan" "app_service_plan" {
name = var.app_service_plan_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
os_type = "Linux"
sku_name = var.sku
depends_on = [azurerm_resource_group.rg]
}
# Web API App Service
resource "azurerm_linux_web_app" "web_api" {
name = var.web_api_app_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
service_plan_id = azurerm_service_plan.app_service_plan.id
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.uami_github.id]
}
key_vault_reference_identity_id = azurerm_user_assigned_identity.uami_github.id
app_settings = {
"CosmosDbConnectionString" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.cosmos_connection_string.id})"
"KeyVaultUrl" = azurerm_key_vault.kv.vault_uri
"AZURE_CLIENT_ID" = azurerm_user_assigned_identity.uami_github.client_id
}
site_config {
application_stack {
dotnet_version = "9.0"
}
}
depends_on = [azurerm_service_plan.app_service_plan]
}
# Blazor Web App Service
resource "azurerm_linux_web_app" "blazor_web" {
name = var.blazor_web_app_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
service_plan_id = azurerm_service_plan.app_service_plan.id
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.uami_github.id]
}
app_settings = {
"KeyVaultUrl" = azurerm_key_vault.kv.vault_uri
}
site_config {
application_stack {
dotnet_version = "9.0"
}
}
depends_on = [azurerm_service_plan.app_service_plan]
}
# Cosmos DB Account
resource "azurerm_cosmosdb_account" "cosmos_db" {
name = var.cosmos_db_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
offer_type = var.cosmos_db_offer_type
kind = "GlobalDocumentDB"
consistency_policy {
consistency_level = "Session"
}
geo_location {
location = azurerm_resource_group.rg.location
failover_priority = 0
}
depends_on = [azurerm_service_plan.app_service_plan]
}
# User Assigned Managed Identity
resource "azurerm_user_assigned_identity" "uami_github" {
name = var.uami_github
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
depends_on = [azurerm_cosmosdb_account.cosmos_db]
}
# Federated Credential for GitHub Actions
resource "azurerm_federated_identity_credential" "uami_github_credential" {
name = "uami-github-githubactions"
resource_group_name = azurerm_resource_group.rg.name
audience = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
parent_id = azurerm_user_assigned_identity.uami_github.id
subject = "repo:[YOUR GITHUB USER]/[YOUR REPO]:ref:refs/heads/main"
}
# Assign Contributor Role for the Resource Group to the Managed Identity
resource "azurerm_role_assignment" "uami_contributor" {
scope = azurerm_resource_group.rg.id
role_definition_name = "Contributor"
principal_id = azurerm_user_assigned_identity.uami_github.principal_id
}
# Key Vault
resource "azurerm_key_vault" "kv" {
name = var.keyvault_moviescosmos1
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
sku_name = "standard"
tenant_id = data.azurerm_client_config.current.tenant_id
access_policy {
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.uami_github.principal_id
secret_permissions = [
"Get",
"List",
"Set",
"Delete"
]
}
access_policy {
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id
secret_permissions = [
"Get",
"List",
"Set",
"Delete"
]
}
depends_on = [azurerm_cosmosdb_account.cosmos_db]
}
# Key Vault Secret
resource "azurerm_key_vault_secret" "cosmos_connection_string" {
name = var.keyvault_moviescosmos1_dbsecret
value = azurerm_cosmosdb_account.cosmos_db.primary_sql_connection_string
key_vault_id = azurerm_key_vault.kv.id
depends_on = [azurerm_cosmosdb_account.cosmos_db]
}
# Outputs
output "resource_group_name" {
value = azurerm_resource_group.rg.name
}
output "web_api_app_url" {
value = azurerm_linux_web_app.web_api.default_hostname
}
output "blazor_web_app_url" {
value = azurerm_linux_web_app.blazor_web.default_hostname
}
output "cosmos_db_name" {
value = azurerm_cosmosdb_account.cosmos_db.name
}
output "uami_github_client_id" {
value = azurerm_user_assigned_identity.uami_github.client_id
}
This Terraform script provisions the necessary infrastructure on Microsoft Azure to host the application. It creates the following resources:
A Resource Group to logically organize all resources.
Two Linux App Services:
One for the Web API backend.
One for the Blazor Web App frontend.
A Cosmos DB Account for storing application data, configured with session consistency and geo-location for high availability.
An Azure Key Vault to securely store secrets like the Cosmos DB connection string, which the apps access via Key Vault references.
A User Assigned Managed Identity for secure authentication, eliminating the need for credentials.
A Federated Credential to enable GitHub Actions to deploy to Azure securely using the Managed Identity.
Role-based access control (RBAC) with a Contributor role assigned to the Managed Identity for managing the resource group.
Most of this stuff is self-explanatory. The interesting part, however, is related to the Managed Identity. I’ve set up a User-Assigned Managed Identity (UAMI) and configured a Federated Credential which allows me to authenticate GitHub Actions to my Azure tenant.
To quote the Microsoft documentation:
Federated identity credentials are a new type of credential that enables workload identity federation for software workloads. Workload identity federation allows you to access Microsoft Entra protected resources without needing to manage secrets (for supported scenarios).
In short, this setup enables GitHub Actions to authenticate seamlessly with Azure without the need to store or expose any credentials that could be exploited by an adversary. The authentication is tightly linked to your GitHub repository and can only be used from it, ensuring a secure and scoped integration for deploying resources.
# User Assigned Managed Identity
resource "azurerm_user_assigned_identity" "uami_github" {
name = var.uami_github
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
depends_on = [azurerm_cosmosdb_account.cosmos_db]
}
# Federated Credential for GitHub Actions
resource "azurerm_federated_identity_credential" "uami_github_credential" {
name = "uami-github-githubactions"
resource_group_name = azurerm_resource_group.rg.name
audience = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
parent_id = azurerm_user_assigned_identity.uami_github.id
subject = "repo:[YOUR GITHUB USER]/[YOUR REPO]:ref:refs/heads/main"
}
Note: Replace
[YOUR GITHUB USER]
with your own GitHub account and [YOUR REPO]
with your repository if you want to try this on your own.
Once I did this, I had to add the following secrets to my GitHub repository:
AZURE_CLIENT_ID: The client id of the Managed Identity
AZURE_SUBSCRIPTION_ID: The subscription id
AZURE_TENANT_ID: The tenant id
Even though I’ve added these secrets to my GitHub repository, they are not credentials that could be reused by an adversary. Instead, they are just identifiers—information that Azure uses to verify the connection.
The actual authentication happens through the Federated Credential tied to the Managed Identity and is scoped specifically to the actions from this GitHub repository. This ensures that even if these values are exposed, they cannot be used to gain unauthorized access to my Azure resources. This setup enhances security while maintaining ease of deployment.
Once that was done, I was able to create a workflow and deploy my application on Microsoft Azure with no credentials stored anywhere.
If you want to try this, feel free to clone the repo and play around with it on your own.
That’s it for today, until next time!