Deploying on Azure

Running an installation on Azure Container Apps with Azure Database for PostgreSQL.

This page deploys Alba Ticket on Azure from the published image, using Azure Container Apps for the application, Typesense and RustFS, Azure Database for PostgreSQL for the database, Azure Files for the disks those containers need, and an SMTP provider for email. It uses the az command line throughout, because Container Apps take their volumes from YAML rather than the portal; everything else can be done in the portal if you prefer.

One thing is different on Azure. Attachments need S3-compatible storage, and Azure Blob Storage is not: it speaks its own API. This page therefore runs RustFS in the environment with its data on an Azure Files share, which suits a small installation; anything larger is better served by an S3-compatible service outside Azure, such as Cloudflare R2 or Backblaze B2, with the STORAGE_* variables pointed at it and the RustFS steps left out. Either way, browsers upload straight to the store, so it needs a public name of its own.

Before you start

  • Two DNS names: one for the application, tickets.example.com below, and one for attachments, files.example.com, both to be pointed at the environment with CNAME records when asked.
  • An SMTP account. Azure Communication Services Email offers SMTP once a domain is verified there (host smtp.azurecomm.net, port 587), and any other provider does as well.
  • The two secrets, generated on your machine and kept somewhere safe; losing CLOAK_KEY makes stored credentials unreadable:
openssl rand -base64 64 | tr -d '\n'   # SECRET_KEY_BASE
openssl rand -base64 32                # CLOAK_KEY
  • The az command line, logged in, with the Container Apps extension: az extension add --name containerapp --upgrade.

The published images at ghcr.io/garth/alba are public, so no registry login is needed. Pick the version to run from Published images; the examples below use 0.7.8, this release. Everything goes in one resource group, alba, in one region:

az group create --name alba --location westeurope

1. The database

Create a flexible server for PostgreSQL 16, with a database called alba. The smallest burstable size suits a small team, and storage can be grown later:

az postgres flexible-server create --resource-group alba --name alba-db \
  --version 16 --tier Burstable --sku-name Standard_B1ms --storage-size 32 \
  --admin-user alba --admin-password '<password>' --database-name alba \
  --public-access 0.0.0.0

--public-access 0.0.0.0 adds the firewall rule that admits connections from Azure services, which is how the container apps reach it without a virtual network; it admits nothing from the internet. Azure requires TLS on the connection and signs its certificates with public authorities the image trusts, so the connection string carries ?ssl=true, which verifies the certificate:

postgres://alba:<password>@alba-db.postgres.database.azure.com:5432/alba?ssl=true

Automated backups are on by default, seven days; that is the database backup described in Backups.

2. The environment and its disks

Create the Container Apps environment, then a storage account with two file shares, for Typesense's index and RustFS's objects, and register each share with the environment; the application itself keeps nothing on disk:

az containerapp env create --resource-group alba --name alba-env --location westeurope

az storage account create --resource-group alba --name albafiles --sku Standard_LRS
KEY=$(az storage account keys list --resource-group alba --account-name albafiles --query '[0].value' -o tsv)

for share in typesense rustfs; do
  az storage share-rm create --resource-group alba --storage-account albafiles --name $share --quota 100
  az containerapp env storage set --resource-group alba --name alba-env --storage-name $share \
    --azure-file-account-name albafiles --azure-file-account-key "$KEY" \
    --azure-file-share-name $share --access-mode ReadWrite
done

az containerapp env show --resource-group alba --name alba-env --query properties.defaultDomain -o tsv

The last command prints the environment's domain, <something>.westeurope.azurecontainerapps.io; it is <env domain> in what follows.

Typesense holds derived data only, rebuilt from the database on demand, so one replica is enough. It takes its settings from environment variables. Save this as typesense.yaml, with a key from openssl rand -base64 32:

properties:
  configuration:
    ingress:
      external: false
      targetPort: 8108
      transport: http
    secrets:
      - name: api-key
        value: <the Typesense key>
  template:
    containers:
      - name: typesense
        image: typesense/typesense:30.2
        env:
          - name: TYPESENSE_DATA_DIR
            value: /data
          - name: TYPESENSE_API_KEY
            secretRef: api-key
        resources:
          cpu: 0.5
          memory: 1Gi
        volumeMounts:
          - volumeName: data
            mountPath: /data
    scale:
      minReplicas: 1
      maxReplicas: 1
    volumes:
      - name: data
        storageType: AzureFile
        storageName: typesense
az containerapp create --resource-group alba --name typesense --environment alba-env --yaml typesense.yaml

Internal ingress gives it a name inside the environment only, typesense.internal.<env domain>, answering on port 80 for the container's 8108; the application's TYPESENSE_URL is http://typesense.internal.<env domain>.

4. Attachments

RustFS is the same shape: one replica, a share mounted on /data, and this time external ingress on its API port so browsers can reach it. Save as rustfs.yaml, with a secret key of your own; RUSTFS_CORS_ALLOWED_ORIGINS is the application's address, because browsers upload straight to the store and RustFS answers no origin it has not been told about:

properties:
  configuration:
    ingress:
      external: true
      targetPort: 9000
      transport: http
    secrets:
      - name: secret-key
        value: <the RustFS secret key>
  template:
    containers:
      - name: rustfs
        image: rustfs/rustfs:1.0.0
        env:
          - name: RUSTFS_ACCESS_KEY
            value: alba
          - name: RUSTFS_SECRET_KEY
            secretRef: secret-key
          - name: RUSTFS_CORS_ALLOWED_ORIGINS
            value: https://tickets.example.com
          - name: RUSTFS_CHECK_UPDATES
            value: "false"
        resources:
          cpu: 0.5
          memory: 1Gi
        volumeMounts:
          - volumeName: data
            mountPath: /data
    scale:
      minReplicas: 1
      maxReplicas: 1
    volumes:
      - name: data
        storageType: AzureFile
        storageName: rustfs
az containerapp create --resource-group alba --name rustfs --environment alba-env --yaml rustfs.yaml

Give it the attachments name with a managed certificate. Add the host name, create the CNAME and the asuid TXT record the first command tells you about, then bind:

az containerapp hostname add --resource-group alba --name rustfs --hostname files.example.com
az containerapp hostname bind --resource-group alba --name rustfs --hostname files.example.com \
  --environment alba-env --validation-method CNAME

There is no bucket to create by hand: the application makes it on first start from the storage variables. Those are STORAGE_ENDPOINT=http://rustfs.internal.<env domain> (the application reaches RustFS inside the environment), STORAGE_PUBLIC_BASE_URL=https://files.example.com (browsers reach it by its public name), STORAGE_BUCKET=alba-attachments, STORAGE_FORCE_PATH_STYLE=true, STORAGE_ACCESS_KEY_ID=alba and the secret key as STORAGE_SECRET_ACCESS_KEY.

RustFS on an Azure Files share is a single node on a network file system. It is fine for a small team; when attachments matter, an S3-compatible service with the STORAGE_* variables pointed at it is the sturdier choice, and workspace export moves everything across when you switch.

5. Migrations

The image does not migrate on start. Run the migrations as a Container Apps job with the application's own environment, started by hand before each version. The secrets and variables here are the same ones the application gets in the next step, so keep the two in step:

az containerapp job create --resource-group alba --name alba-migrate --environment alba-env \
  --trigger-type Manual --replica-timeout 1800 --replica-retry-limit 1 \
  --image ghcr.io/garth/alba:0.7.8 --command /app/bin/migrate \
  --cpu 0.5 --memory 1Gi \
  --secrets database-url='postgres://alba:<password>@alba-db.postgres.database.azure.com:5432/alba?ssl=true' \
            secret-key-base='<SECRET_KEY_BASE>' cloak-key='<CLOAK_KEY>' \
  --env-vars DATABASE_URL=secretref:database-url SECRET_KEY_BASE=secretref:secret-key-base \
             CLOAK_KEY=secretref:cloak-key PHX_HOST=tickets.example.com

az containerapp job start --resource-group alba --name alba-migrate
az containerapp job execution list --resource-group alba --name alba-migrate -o table

The execution list shows Succeeded when the migrations are done; az containerapp job logs show says why when it does not.

6. The application

Save this as alba.yaml. It carries every variable the application needs; the SMTP values are your provider's:

properties:
  configuration:
    ingress:
      external: true
      targetPort: 4000
      transport: auto
    secrets:
      - name: database-url
        value: postgres://alba:<password>@alba-db.postgres.database.azure.com:5432/alba?ssl=true
      - name: secret-key-base
        value: <SECRET_KEY_BASE>
      - name: cloak-key
        value: <CLOAK_KEY>
      - name: typesense-api-key
        value: <the Typesense key>
      - name: storage-secret
        value: <the RustFS secret key>
      - name: smtp-password
        value: <the SMTP password>
  template:
    containers:
      - name: app
        image: ghcr.io/garth/alba:0.7.8
        env:
          - name: DATABASE_URL
            secretRef: database-url
          - name: SECRET_KEY_BASE
            secretRef: secret-key-base
          - name: CLOAK_KEY
            secretRef: cloak-key
          - name: PHX_HOST
            value: tickets.example.com
          - name: PHX_SCHEME
            value: https
          - name: PORT
            value: "4000"
          - name: TYPESENSE_URL
            value: http://typesense.internal.<env domain>
          - name: TYPESENSE_API_KEY
            secretRef: typesense-api-key
          - name: STORAGE_ENDPOINT
            value: http://rustfs.internal.<env domain>
          - name: STORAGE_PUBLIC_BASE_URL
            value: https://files.example.com
          - name: STORAGE_BUCKET
            value: alba-attachments
          - name: STORAGE_FORCE_PATH_STYLE
            value: "true"
          - name: STORAGE_ACCESS_KEY_ID
            value: alba
          - name: STORAGE_SECRET_ACCESS_KEY
            secretRef: storage-secret
          - name: SMTP_HOST
            value: smtp.azurecomm.net
          - name: SMTP_PORT
            value: "587"
          - name: SMTP_USERNAME
            value: <the SMTP user name>
          - name: SMTP_PASSWORD
            secretRef: smtp-password
          - name: SMTP_TLS
            value: always
          - name: MAIL_FROM
            value: tickets@example.com
        resources:
          cpu: 1
          memory: 2Gi
        probes:
          - type: Readiness
            httpGet:
              path: /health
              port: 4000
            periodSeconds: 5
          - type: Liveness
            httpGet:
              path: /health
              port: 4000
            initialDelaySeconds: 30
            periodSeconds: 15
    scale:
      minReplicas: 1
      maxReplicas: 1
az containerapp create --resource-group alba --name alba --environment alba-env --yaml alba.yaml
az containerapp hostname add --resource-group alba --name alba --hostname tickets.example.com
az containerapp hostname bind --resource-group alba --name alba --hostname tickets.example.com \
  --environment alba-env --validation-method CNAME

Container Apps ingress passes WebSocket connections, which live updates depend on, and terminates TLS with the managed certificate, which is why PHX_SCHEME is https. minReplicas: 1 keeps the application from being scaled to zero, which would make the first visit after a quiet spell wait for a start; keep maxReplicas at 1 as well, since Container Apps give replicas no name they can find each other by, so they cannot form the cluster Clustering describes. Several nodes on Azure means AKS, following the Kubernetes section of Installation.

7. First visit

Open https://tickets.example.com. An installation with no users shows the setup page: it creates the first administrator, with a password so that login works before email delivery is confirmed, and names the installation. Attachment storage is registered on first start from the STORAGE_* variables; check under Administration, Storage that the location verifies, which writes, reads and deletes one small object. Send yourself an invitation from Invitations to confirm mail delivery, and run Rebuild index on the Search page under Administration if search returns nothing.

Upgrading

  1. Check the database's backups, or take one on demand from its Backup and restore page.
  2. Point the migration job at the new tag and run it: az containerapp job update --resource-group alba --name alba-migrate --image ghcr.io/garth/alba:<tag> then az containerapp job start, and wait for Succeeded.
  3. Update the application: az containerapp update --resource-group alba --name alba --image ghcr.io/garth/alba:<tag>. A new revision starts, takes traffic once its readiness probe passes, and the old one stops; people's browsers reconnect by themselves.

The notes in Upgrades about specific versions apply here too.

Importing from Jira

Upload the backup under Administration, Jira import. It goes straight from the browser to RustFS, under an imports/ key of its own, and the import reads it from there; nothing has to reach a file share. An upload is removed after a day.

Operating notes

  • Logs. az containerapp logs show --resource-group alba --name alba --follow streams the application's log; the environment's Log Analytics workspace keeps it. Look there for Postgrex connection errors, job failures and mail delivery errors.
  • Backups. The database's automated backups, a backup of the rustfs share (Azure Backup covers file shares) for attachments, and the two secrets kept somewhere safe; see Backups. The typesense share is derived and needs no backup.
  • Sizing. 1 CPU and 2 GB for the application and half that each for Typesense and RustFS suit a small team; see Requirements. Container Apps are billed for what the replicas use, the database and the storage account by the hour and by what is stored.
  • Secrets live in each app's configuration; az containerapp secret set changes one, and a new revision picks it up.

Troubleshooting

  • The migration job fails at once. Its log says why. SSL connection is required means ?ssl=true is missing from the URL; a connection refused or timed out means the server's firewall lacks the rule that admits Azure services, which --public-access 0.0.0.0 adds and the portal calls Allow public access from any Azure service within Azure to this server.
  • The application's revision never becomes ready. az containerapp logs show shows whether it started; a secret referenced by a name that does not exist stops the revision before the container runs, and the revision's status says so.
  • The storage location does not verify. STORAGE_ENDPOINT must be the internal name, on http with no port, and STORAGE_FORCE_PATH_STYLE must be true. The error under Administration, Storage quotes the store's answer; The specified bucket does not exist means the application could not create the bucket on first start, and its log says why.
  • Uploads fail in the browser but the location verifies. STORAGE_PUBLIC_BASE_URL must be the bound attachments name with https, and its certificate must have been issued: az containerapp hostname list shows the binding state. The browser console shows the blocked request.
  • The certificate is not issued. The CNAME and the asuid TXT record must both resolve before hostname bind; run it again once they do.
  • No email arrives. The application log shows the server's reply: a sender not on the verified domain, or credentials the provider does not accept over SMTP.

See Troubleshooting for problems that are not specific to Azure.