From 8ff2c1529854015861dc0c9e9207cfd5b53b3ac7 Mon Sep 17 00:00:00 2001 From: Shivam Date: Thu, 28 May 2026 11:24:01 +0530 Subject: [PATCH 1/3] docs(deploy): add Azure Container Instances guide --- deploy/azure/azure-pipelines.yml | 51 ++++++++ deploy/azure/container-instance.bicep | 176 ++++++++++++++++++++++++++ docs/deployment/cloud/azure.md | 127 +++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 deploy/azure/azure-pipelines.yml create mode 100644 deploy/azure/container-instance.bicep create mode 100644 docs/deployment/cloud/azure.md diff --git a/deploy/azure/azure-pipelines.yml b/deploy/azure/azure-pipelines.yml new file mode 100644 index 000000000..a023e98da --- /dev/null +++ b/deploy/azure/azure-pipelines.yml @@ -0,0 +1,51 @@ +trigger: none + +parameters: + - name: azureServiceConnection + type: string + default: '' + +variables: + vmImageName: ubuntu-latest + resourceGroupName: open-design-aci + deploymentName: open-design-aci + location: eastus + templateFile: deploy/azure/container-instance.bicep + openDesignImage: docker.io/vanjayak/open-design:latest + dnsNameLabel: open-design-$(Build.BuildId) + +pool: + vmImage: $(vmImageName) + +steps: + - checkout: self + + - task: AzureCLI@2 + displayName: Deploy Open Design to Azure Container Instances + inputs: + azureSubscription: ${{ parameters.azureServiceConnection }} + scriptType: bash + scriptLocation: inlineScript + useGlobalConfig: false + inlineScript: | + set -euo pipefail + + if [ -z "${OD_API_TOKEN:-}" ]; then + echo "Set OD_API_TOKEN as a secret pipeline variable before running this pipeline." >&2 + exit 1 + fi + + az group create \ + --name "$(resourceGroupName)" \ + --location "$(location)" + + az deployment group create \ + --name "$(deploymentName)" \ + --resource-group "$(resourceGroupName)" \ + --template-file "$(templateFile)" \ + --parameters \ + odApiToken="$OD_API_TOKEN" \ + image="$(openDesignImage)" \ + dnsNameLabel="$(dnsNameLabel)" + env: + OD_API_TOKEN: $(OD_API_TOKEN) diff --git a/deploy/azure/container-instance.bicep b/deploy/azure/container-instance.bicep new file mode 100644 index 000000000..53eca76f3 --- /dev/null +++ b/deploy/azure/container-instance.bicep @@ -0,0 +1,176 @@ +targetScope = 'resourceGroup' + +@description('Azure region for the Open Design container group and storage account.') +param location string = resourceGroup().location + +@description('Container group name.') +param containerGroupName string = 'open-design' + +@description('DNS label for the public Azure Container Instances endpoint. Must be unique in the selected region.') +param dnsNameLabel string = toLower('open-design-${uniqueString(resourceGroup().id, location)}') + +@description('Open Design container image.') +param image string = 'docker.io/vanjayak/open-design:latest' + +@secure() +@description('Required Open Design API token. Generate with: openssl rand -hex 32') +param odApiToken string + +@description('Comma-separated browser origins allowed to call the daemon API when placed behind a custom domain or reverse proxy.') +param allowedOrigins string = '' + +@description('Node.js heap cap inside the container.') +param nodeOptions string = '--max-old-space-size=192' + +@description('CPU cores requested by the container.') +@minValue(1) +param cpuCores int = 1 + +@description('Memory requested by the container in GiB.') +@minValue(1) +param memoryInGB int = 1 + +@description('Azure Files share quota in GiB for persistent Open Design data.') +@minValue(1) +@maxValue(5120) +param fileShareQuotaGB int = 10 + +var storageAccountName = take(toLower('od${uniqueString(resourceGroup().id, location)}'), 24) +var fileShareName = 'opendesigndata' +var appPort = 7456 + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + } +} + +resource fileService 'Microsoft.Storage/storageAccounts/fileServices@2023-05-01' = { + parent: storageAccount + name: 'default' +} + +resource dataShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = { + parent: fileService + name: fileShareName + properties: { + accessTier: 'TransactionOptimized' + shareQuota: fileShareQuotaGB + } +} + +resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2023-05-01' = { + name: containerGroupName + location: location + properties: { + osType: 'Linux' + restartPolicy: 'Always' + sku: 'Standard' + ipAddress: { + type: 'Public' + dnsNameLabel: dnsNameLabel + ports: [ + { + protocol: 'TCP' + port: appPort + } + ] + } + containers: [ + { + name: 'open-design' + properties: { + image: image + ports: [ + { + protocol: 'TCP' + port: appPort + } + ] + environmentVariables: [ + { + name: 'NODE_ENV' + value: 'production' + } + { + name: 'NODE_OPTIONS' + value: nodeOptions + } + { + name: 'OD_BIND_HOST' + value: '0.0.0.0' + } + { + name: 'OD_PORT' + value: string(appPort) + } + { + name: 'OD_WEB_PORT' + value: string(appPort) + } + { + name: 'OD_DATA_DIR' + value: '/app/.od' + } + { + name: 'OD_ALLOWED_ORIGINS' + value: allowedOrigins + } + { + name: 'OD_API_TOKEN' + secureValue: odApiToken + } + ] + resources: { + requests: { + cpu: cpuCores + memoryInGB: memoryInGB + } + } + volumeMounts: [ + { + name: 'open-design-data' + mountPath: '/app/.od' + readOnly: false + } + ] + livenessProbe: { + httpGet: { + path: '/api/health' + port: appPort + scheme: 'http' + } + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 5 + } + } + } + ] + volumes: [ + { + name: 'open-design-data' + azureFile: { + shareName: dataShare.name + storageAccountName: storageAccount.name + storageAccountKey: listKeys(storageAccount.id, storageAccount.apiVersion).keys[0].value + readOnly: false + } + } + ] + } +} + +output fqdn string = containerGroup.properties.ipAddress.fqdn +output url string = 'http://${containerGroup.properties.ipAddress.fqdn}:${appPort}/' +output storageAccountName string = storageAccount.name +output fileShareName string = dataShare.name diff --git a/docs/deployment/cloud/azure.md b/docs/deployment/cloud/azure.md new file mode 100644 index 000000000..3b63ec278 --- /dev/null +++ b/docs/deployment/cloud/azure.md @@ -0,0 +1,127 @@ +# Azure Container Instances + +This guide deploys the Docker image to Azure Container Instances (ACI) with an Azure Files share mounted at `/app/.od` for persistent Open Design data. + +## Before You Start + +- Azure CLI installed and signed in +- Permission to create a resource group, storage account, file share, and container group +- A public Docker image, or an image in a registry that ACI can pull + +## Step 1: Choose Names + +```bash +export RESOURCE_GROUP=open-design-aci +export LOCATION=eastus +export DEPLOYMENT_NAME=open-design-aci +export DNS_LABEL=open-design-$RANDOM +export OD_API_TOKEN="$(openssl rand -hex 32)" +``` + +The DNS label must be unique in the Azure region. The API token is required because this deployment exposes the daemon on `0.0.0.0` inside the container. + +## Step 2: Create The Resource Group + +```bash +az group create \ + --name "$RESOURCE_GROUP" \ + --location "$LOCATION" +``` + +## Step 3: Deploy The Bicep Template + +Run this from the repository root: + +```bash +az deployment group create \ + --name "$DEPLOYMENT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --template-file deploy/azure/container-instance.bicep \ + --parameters \ + location="$LOCATION" \ + dnsNameLabel="$DNS_LABEL" \ + odApiToken="$OD_API_TOKEN" +``` + +The template creates: + +- Azure Storage account +- Azure Files share for `/app/.od` +- Linux Azure Container Instances container group +- Public DNS name and TCP port `7456` +- Liveness probe against `/api/health` + +## Step 4: Open Open Design + +Fetch the deployment URL: + +```bash +az deployment group show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$DEPLOYMENT_NAME" \ + --query "properties.outputs.url.value" \ + --output tsv +``` + +Open the returned URL in your browser. + +## Optional Parameters + +```bash +az deployment group create \ + --name "$DEPLOYMENT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --template-file deploy/azure/container-instance.bicep \ + --parameters \ + odApiToken="$OD_API_TOKEN" \ + dnsNameLabel="$DNS_LABEL" \ + image="docker.io/vanjayak/open-design:latest" \ + cpuCores=1 \ + memoryInGB=1 \ + fileShareQuotaGB=10 \ + allowedOrigins="https://od.example.com" +``` + +Use `allowedOrigins` only when a custom domain or reverse proxy serves the browser from an origin different from the ACI endpoint. + +## Azure DevOps + +Use `deploy/azure/azure-pipelines.yml` as a starting point. + +Before running it: + +- Create an Azure Resource Manager service connection. +- Set `OD_API_TOKEN` as a secret pipeline variable. +- Update `resourceGroupName`, `location`, and `openDesignImage`. +- Replace `` with your service connection name. + +## Operations + +View logs: + +```bash +az container logs \ + --resource-group "$RESOURCE_GROUP" \ + --name open-design +``` + +Restart the container group: + +```bash +az container restart \ + --resource-group "$RESOURCE_GROUP" \ + --name open-design +``` + +Delete all Azure resources created by this guide: + +```bash +az group delete \ + --name "$RESOURCE_GROUP" +``` + +## Security Notes + +- ACI's direct public endpoint is plain HTTP. Put it behind Azure Front Door, Application Gateway, or another TLS-terminating reverse proxy before using it beyond short-lived evaluation. +- Keep `OD_API_TOKEN` secret. Rotate it by redeploying with a new value. +- The Azure Files share persists project data after container restarts and image updates. From b7c979e35fa4f41634ebc5bb23d5f9a2b401c5e6 Mon Sep 17 00:00:00 2001 From: Shivam Date: Thu, 28 May 2026 11:42:36 +0530 Subject: [PATCH 2/3] docs(deploy): clarify Azure proxy topology --- deploy/azure/azure-pipelines.yml | 9 +++- deploy/azure/container-instance.bicep | 8 ++-- docs/deployment/cloud/azure.md | 66 +++++++++++++++++++++------ 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/deploy/azure/azure-pipelines.yml b/deploy/azure/azure-pipelines.yml index a023e98da..e78419ae8 100644 --- a/deploy/azure/azure-pipelines.yml +++ b/deploy/azure/azure-pipelines.yml @@ -13,6 +13,7 @@ variables: templateFile: deploy/azure/container-instance.bicep openDesignImage: docker.io/vanjayak/open-design:latest dnsNameLabel: open-design-$(Build.BuildId) + browserOrigin: https://od.example.com pool: vmImage: $(vmImageName) @@ -35,6 +36,11 @@ steps: exit 1 fi + if [ "$(browserOrigin)" = "https://od.example.com" ]; then + echo "Set browserOrigin to the TLS origin served by your authenticated reverse proxy." >&2 + exit 1 + fi + az group create \ --name "$(resourceGroupName)" \ --location "$(location)" @@ -46,6 +52,7 @@ steps: --parameters \ odApiToken="$OD_API_TOKEN" \ image="$(openDesignImage)" \ - dnsNameLabel="$(dnsNameLabel)" + dnsNameLabel="$(dnsNameLabel)" \ + allowedOrigins="$(browserOrigin)" env: OD_API_TOKEN: $(OD_API_TOKEN) diff --git a/deploy/azure/container-instance.bicep b/deploy/azure/container-instance.bicep index 53eca76f3..7832b7bb5 100644 --- a/deploy/azure/container-instance.bicep +++ b/deploy/azure/container-instance.bicep @@ -6,7 +6,7 @@ param location string = resourceGroup().location @description('Container group name.') param containerGroupName string = 'open-design' -@description('DNS label for the public Azure Container Instances endpoint. Must be unique in the selected region.') +@description('DNS label for the Azure Container Instances upstream endpoint. Must be unique in the selected region.') param dnsNameLabel string = toLower('open-design-${uniqueString(resourceGroup().id, location)}') @description('Open Design container image.') @@ -16,7 +16,7 @@ param image string = 'docker.io/vanjayak/open-design:latest' @description('Required Open Design API token. Generate with: openssl rand -hex 32') param odApiToken string -@description('Comma-separated browser origins allowed to call the daemon API when placed behind a custom domain or reverse proxy.') +@description('Comma-separated browser-visible origins allowed by the daemon. Set this to the authenticated reverse proxy origin, for example https://od.example.com.') param allowedOrigins string = '' @description('Node.js heap cap inside the container.') @@ -170,7 +170,7 @@ resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2023-05-01' } } -output fqdn string = containerGroup.properties.ipAddress.fqdn -output url string = 'http://${containerGroup.properties.ipAddress.fqdn}:${appPort}/' +output daemonFqdn string = containerGroup.properties.ipAddress.fqdn +output proxyUpstreamUrl string = 'http://${containerGroup.properties.ipAddress.fqdn}:${appPort}' output storageAccountName string = storageAccount.name output fileShareName string = dataShare.name diff --git a/docs/deployment/cloud/azure.md b/docs/deployment/cloud/azure.md index 3b63ec278..61f2ea27c 100644 --- a/docs/deployment/cloud/azure.md +++ b/docs/deployment/cloud/azure.md @@ -2,6 +2,8 @@ This guide deploys the Docker image to Azure Container Instances (ACI) with an Azure Files share mounted at `/app/.od` for persistent Open Design data. +ACI is the daemon upstream in this topology. The browser-facing app URL must be served by an authenticated TLS reverse proxy that forwards traffic to ACI, injects the daemon bearer token on `/api/*` requests, and sends a browser origin listed in `OD_ALLOWED_ORIGINS`. + ## Before You Start - Azure CLI installed and signed in @@ -15,10 +17,11 @@ export RESOURCE_GROUP=open-design-aci export LOCATION=eastus export DEPLOYMENT_NAME=open-design-aci export DNS_LABEL=open-design-$RANDOM +export BROWSER_ORIGIN=https://od.example.com export OD_API_TOKEN="$(openssl rand -hex 32)" ``` -The DNS label must be unique in the Azure region. The API token is required because this deployment exposes the daemon on `0.0.0.0` inside the container. +The DNS label must be unique in the Azure region. `BROWSER_ORIGIN` is the HTTPS origin users will open after a trusted proxy is in front of the daemon. The API token is required because the daemon binds to `0.0.0.0` inside the container; keep this token in your proxy or deployment secrets and do not expose it to browser code. ## Step 2: Create The Resource Group @@ -40,6 +43,7 @@ az deployment group create \ --parameters \ location="$LOCATION" \ dnsNameLabel="$DNS_LABEL" \ + allowedOrigins="$BROWSER_ORIGIN" \ odApiToken="$OD_API_TOKEN" ``` @@ -48,22 +52,55 @@ The template creates: - Azure Storage account - Azure Files share for `/app/.od` - Linux Azure Container Instances container group -- Public DNS name and TCP port `7456` +- Public upstream DNS name and TCP port `7456` - Liveness probe against `/api/health` -## Step 4: Open Open Design +## Step 4: Fetch The ACI Upstream -Fetch the deployment URL: +Fetch the daemon upstream host for your reverse proxy: ```bash -az deployment group show \ +export ACI_FQDN="$(az deployment group show \ --resource-group "$RESOURCE_GROUP" \ --name "$DEPLOYMENT_NAME" \ - --query "properties.outputs.url.value" \ - --output tsv + --query "properties.outputs.daemonFqdn.value" \ + --output tsv)" +export ACI_UPSTREAM_URL="http://${ACI_FQDN}:7456" ``` -Open the returned URL in your browser. +Do not open this URL directly in a browser. The daemon requires `Authorization: Bearer ` on non-loopback `/api/*` requests, and the web UI does not put that secret in browser requests. + +## Step 5: Put A Trusted Proxy In Front + +Serve `BROWSER_ORIGIN` from a TLS reverse proxy that authenticates users before forwarding traffic to the ACI upstream. The proxy must add the daemon token to API requests: + +```nginx +upstream open_design_aci { + server :7456; +} + +server { + listen 443 ssl; + server_name od.example.com; + + # Add your organization's authentication layer here before proxying. + + location /api/ { + proxy_set_header Authorization "Bearer "; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://open_design_aci; + } + + location / { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://open_design_aci; + } +} +``` + +Replace `` with `ACI_FQDN`, replace `` with the same secret passed to the Bicep deployment, and keep `BROWSER_ORIGIN` equal to the origin served by the proxy. After the proxy is configured, open `BROWSER_ORIGIN` in your browser. ## Optional Parameters @@ -75,14 +112,14 @@ az deployment group create \ --parameters \ odApiToken="$OD_API_TOKEN" \ dnsNameLabel="$DNS_LABEL" \ + allowedOrigins="$BROWSER_ORIGIN" \ image="docker.io/vanjayak/open-design:latest" \ cpuCores=1 \ memoryInGB=1 \ - fileShareQuotaGB=10 \ - allowedOrigins="https://od.example.com" + fileShareQuotaGB=10 ``` -Use `allowedOrigins` only when a custom domain or reverse proxy serves the browser from an origin different from the ACI endpoint. +Set `allowedOrigins` to the comma-separated browser origins served by trusted proxies. A direct public ACI browser URL is not supported because browser API calls cannot safely carry the daemon token. ## Azure DevOps @@ -92,7 +129,7 @@ Before running it: - Create an Azure Resource Manager service connection. - Set `OD_API_TOKEN` as a secret pipeline variable. -- Update `resourceGroupName`, `location`, and `openDesignImage`. +- Update `resourceGroupName`, `location`, `openDesignImage`, and `browserOrigin`. - Replace `` with your service connection name. ## Operations @@ -122,6 +159,7 @@ az group delete \ ## Security Notes -- ACI's direct public endpoint is plain HTTP. Put it behind Azure Front Door, Application Gateway, or another TLS-terminating reverse proxy before using it beyond short-lived evaluation. -- Keep `OD_API_TOKEN` secret. Rotate it by redeploying with a new value. +- Do not expose the raw ACI endpoint as the browser-facing app URL. Use it as the upstream for a trusted proxy or for token-authenticated operational checks. +- Keep `OD_API_TOKEN` secret. The proxy may use it upstream, but browser clients must not receive it. Rotate it by redeploying with a new value. +- Keep `allowedOrigins` aligned with the browser-visible proxy origin; otherwise the daemon origin guard will reject browser API requests. - The Azure Files share persists project data after container restarts and image updates. From 4270bea576477cf3ec3372ce5135116886baeb0e Mon Sep 17 00:00:00 2001 From: Shivam Date: Thu, 28 May 2026 13:22:13 +0530 Subject: [PATCH 3/3] docs(deploy): keep Azure proxy streams unbuffered --- docs/deployment/cloud/azure.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/deployment/cloud/azure.md b/docs/deployment/cloud/azure.md index 61f2ea27c..85c80f1f5 100644 --- a/docs/deployment/cloud/azure.md +++ b/docs/deployment/cloud/azure.md @@ -89,6 +89,13 @@ server { proxy_set_header Authorization "Bearer "; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + gzip off; proxy_pass http://open_design_aci; } @@ -100,7 +107,7 @@ server { } ``` -Replace `` with `ACI_FQDN`, replace `` with the same secret passed to the Bicep deployment, and keep `BROWSER_ORIGIN` equal to the origin served by the proxy. After the proxy is configured, open `BROWSER_ORIGIN` in your browser. +Replace `` with `ACI_FQDN`, replace `` with the same secret passed to the Bicep deployment, and keep `BROWSER_ORIGIN` equal to the origin served by the proxy. Keep the `/api/` buffering, gzip, HTTP/1.1, and timeout directives in place so streamed generation responses are not cut off by nginx. After the proxy is configured, open `BROWSER_ORIGIN` in your browser. ## Optional Parameters