Skip to content
IaC Templates

Audio post-production worker

Template · Updated Jul 2026
Validated Jul 2026

Audio post-production worker

This pattern composes Compute, Network, and Object Storage.

What this template deploys#

  • Private network and router for egress-only worker access
  • Two Object Storage buckets (input and output)
  • One Compute worker instance with cloud-init that installs FFmpeg, the AWS CLI, and a polling worker service
  • Optional whisper.cpp transcription when enable_transcription is true
  • 0 floating IPs by default (worker reaches buckets over outbound HTTPS)

Upload raw audio to the input bucket. The worker writes loudness-normalized MP3 files to the output bucket and optionally POSTs to webhook_url.

Prerequisites#

  • OpenTofu or Terraform >= 1.6.0
  • OpenStack application credentials (source openrc.sh)
  • EC2-compatible Object Storage credentials (openstack ec2 credentials create). See S3 storage ACL template for the bootstrap shape.

Parameters#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist in your project)required
s3_access_key / s3_secret_keyEC2-compat Object Storage credentials passed to the workerrequired
input_bucket_nameBucket for raw uploadsrequired
output_bucket_nameBucket for mastered outputrequired
worker_flavorCPU flavor for FFmpegc2a.large
webhook_urlHTTPS callback after each job (empty disables)""
enable_transcriptionUpload SRT sidecars via whisperfalse
poll_interval_secondsSeconds between bucket polls60
target_lufsIntegrated loudness target-14
image_nameBoot image nameUbuntu-24.04
external_networkShared external network for router gatewayPublicStatic
private_cidrPrivate subnet CIDR for the worker192.168.70.0/24
instance_nameWorker instance display nameaudio-worker
s3_endpointQuake AI S3-compatible endpoint URLhttps://object.us-east-1.rumble.cloud
s3_regionS3 region identifier for the AWS providerus-east-1

Cost and sizing#

Size worker_flavor for concurrent FFmpeg jobs you expect. The template uses CPU-only processing (no GPU). Object Storage charges follow your plan allowance for stored and transferred bytes.

When to use this pattern#

Batch-normalize podcast or voiceover files uploaded to Object Storage without standing up a separate media SaaS. For general cloud-init background, see cloud-init and first-boot configuration.

Estimated cost#

Monthly cost estimate

Pricing calculator ↗

Sized as a custom package on dedicated vCPU.

Starting template$60.20/mo

Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.

What each resource is for

Worker node

c2a.large · 2 dedicated vCPU, 4 GiB RAM, 0.5 Gbps

$62.00/mo

Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.

Included in baseline

c2a.large

2 dedicated vCPU, 4 GiB RAM, 0.5 Gbps

$62.00

Compute + RAM rate basis

2 vCPU + 4 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.

—

Block storage (40 GiB)

40 GiB at $0.08/GiB/mo

$3.20

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

Object storage (usage-based)

Object storage

2 buckets. The first 1 TB is included, then $10.00 per TB each month. You pay for what you store, so this line depends on usage.

$0–$40/mo

Assumes: 2 TB stored is $10/mo; 5 TB stored is $40/mo. Within the included allotment it stays $0.

Included at no charge

These line items are zero on Quake AI. Many other providers meter them separately.

Data transfer (inbound and outbound)

Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.

AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.

Learn more
$0.00

Object storage upload and download

No separate charges for uploading or downloading object storage data.

Most object storage providers meter egress and API requests separately from stored capacity.

Learn more
$0.00

Private networking

Private networks, subnets, Neutron routers, and security groups are included with the plan.

VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.

$0.00

Control-plane API requests

OpenStack API calls for provisioning and management are included.

Some managed services on other clouds meter API calls or charge for premium control-plane features.

$0.00

Configure your estimate

Check the add-ons you plan to deploy to build a monthly total. Nothing is selected to start, so the total below begins at the baseline.

Starting template

The required baseline, always included.

$60.20/mo

Pick how much you expect to store to fold it into the total.

$0.00/mo
Your configured estimate$60.20/mo

Dev/test vs production

Start on shared CPU for dev/test, then promote to dedicated for production with a flavor resize. The network, storage, and template stay the same.

Dev/test on shared CPU

Burstable s1a flavors; suited to prototyping and low or bursty load.

$14.70/mo

Production on dedicated CPU

The headline estimate above; predictable steady-load performance.

$60.20/mo

Saves $45.50/mo while you build on shared CPU.

Shared flavors carry less RAM (c2a.large (4 GiB RAM) -> s1a.small (2 GiB RAM)). A resize reboots the instance; data on attached volumes persists. Size the dedicated flavor for the RAM your production workload needs.

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

7 files. Download the zip or expand to copy any file.Download audio-worker.zip
Show source (7 files)
main.tfHCL
data "openstack_images_image_v2" "os" {
  name        = var.image_name
  most_recent = true
}

data "openstack_networking_network_v2" "external" {
  name = var.external_network
}

resource "aws_s3_bucket" "input" {
  bucket = var.input_bucket_name
}

resource "aws_s3_bucket_acl" "input" {
  bucket = aws_s3_bucket.input.id
  acl    = "private"
}

resource "aws_s3_bucket" "output" {
  bucket = var.output_bucket_name
}

resource "aws_s3_bucket_acl" "output" {
  bucket = aws_s3_bucket.output.id
  acl    = "private"
}


resource "openstack_networking_network_v2" "private" {
  name           = "${var.instance_name}-private"
  admin_state_up = true
}

resource "openstack_networking_subnet_v2" "private" {
  name            = "${var.instance_name}-private-sn"
  network_id      = openstack_networking_network_v2.private.id
  cidr            = var.private_cidr
  ip_version      = 4
  enable_dhcp     = true
  dns_nameservers = ["8.8.8.8", "8.8.4.4"]
}

resource "openstack_networking_router_v2" "router" {
  name                = "${var.instance_name}-router"
  external_network_id = data.openstack_networking_network_v2.external.id
  admin_state_up      = true
}

resource "openstack_networking_router_interface_v2" "private" {
  router_id = openstack_networking_router_v2.router.id
  subnet_id = openstack_networking_subnet_v2.private.id
}

resource "openstack_networking_secgroup_v2" "worker" {
  name        = "${var.instance_name}-sg"
  description = "SSH from admin CIDR only; worker is egress-only to Object Storage"
}

resource "openstack_networking_secgroup_rule_v2" "ssh" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
  remote_ip_prefix  = "0.0.0.0/0"
  security_group_id = openstack_networking_secgroup_v2.worker.id
}

resource "openstack_networking_port_v2" "worker" {
  name               = "${var.instance_name}-port"
  network_id         = openstack_networking_network_v2.private.id
  security_group_ids = [openstack_networking_secgroup_v2.worker.id]

  fixed_ip {
    subnet_id = openstack_networking_subnet_v2.private.id
  }

  depends_on = [openstack_networking_router_interface_v2.private]
}

resource "openstack_compute_instance_v2" "worker" {
  name        = var.instance_name
  flavor_name = var.worker_flavor
  key_pair    = var.key_name

  user_data = templatefile("${path.module}/cloud-init/worker.yaml", {
    s3_endpoint           = var.s3_endpoint
    s3_region             = var.s3_region
    s3_access_key         = var.s3_access_key
    s3_secret_key         = var.s3_secret_key
    input_bucket          = aws_s3_bucket.input.id
    output_bucket         = aws_s3_bucket.output.id
    webhook_url           = var.webhook_url
    enable_transcription  = var.enable_transcription ? "true" : "false"
    poll_interval_seconds = var.poll_interval_seconds
    target_lufs           = var.target_lufs
  })

  block_device {
    uuid                  = data.openstack_images_image_v2.os.id
    source_type           = "image"
    destination_type      = "volume"
    volume_size           = 40
    boot_index            = 0
    delete_on_termination = true
  }

  network {
    port = openstack_networking_port_v2.worker.id
  }
}
variables.tfHCL
variable "key_name" {
  description = "Existing SSH keypair name in the project for compute instances"
  type        = string
}

variable "s3_access_key" {
  description = "EC2-compatible access key for Object Storage (Keystone ec2 credentials create)"
  type        = string
  sensitive   = true
}

variable "s3_secret_key" {
  description = "EC2-compatible secret key paired with s3_access_key"
  type        = string
  sensitive   = true
}

variable "input_bucket_name" {
  description = "S3 bucket name for raw audio uploads"
  type        = string
}

variable "output_bucket_name" {
  description = "S3 bucket name for mastered audio output"
  type        = string
}

variable "worker_flavor" {
  description = "Compute flavor for the FFmpeg worker (CPU-only)"
  type        = string
  default     = "c2a.large"
}

variable "image_name" {
  description = "Boot image name"
  type        = string
  default     = "Ubuntu-24.04"
}

variable "external_network" {
  description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
  type        = string
  default     = "PublicStatic"
}

variable "private_cidr" {
  description = "Private subnet CIDR for the worker"
  type        = string
  default     = "192.168.70.0/24"
}

variable "instance_name" {
  description = "Worker instance display name"
  type        = string
  default     = "audio-worker"
}

variable "s3_endpoint" {
  description = "Quake AI S3-compatible endpoint URL"
  type        = string
  default     = "https://object.us-east-1.rumble.cloud"
}

variable "s3_region" {
  description = "S3 region identifier passed to the AWS provider"
  type        = string
  default     = "us-east-1"
}

variable "webhook_url" {
  description = "Optional HTTPS URL the worker POSTs to after each successful job (empty disables webhooks)"
  type        = string
  default     = ""
}

variable "enable_transcription" {
  description = "When true, run whisper.cpp and upload SRT/VTT alongside mastered audio"
  type        = bool
  default     = false
}

variable "poll_interval_seconds" {
  description = "Seconds between input-bucket polls"
  type        = number
  default     = 60
}

variable "target_lufs" {
  description = "Integrated loudness target for FFmpeg loudnorm (LUFS)"
  type        = number
  default     = -14
}
outputs.tfHCL
output "worker_instance_id" {
  description = "Nova instance ID for the audio worker"
  value       = openstack_compute_instance_v2.worker.id
}

output "worker_private_ip" {
  description = "Private IPv4 address of the worker (no floating IP allocated by default)"
  value       = openstack_compute_instance_v2.worker.network[0].fixed_ip_v4
}

output "input_bucket" {
  description = "Object Storage bucket for raw uploads"
  value       = aws_s3_bucket.input.id
}

output "output_bucket" {
  description = "Object Storage bucket for mastered audio"
  value       = aws_s3_bucket.output.id
}

output "floating_ip_count" {
  description = "Floating IPs this template allocates (zero by default; worker is egress-only)"
  value       = 0
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "openstack" {}

provider "aws" {
  region                      = var.s3_region
  access_key                  = var.s3_access_key
  secret_key                  = var.s3_secret_key
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = var.s3_endpoint
  }
}
terraform.tfvars.exampleHCL
key_name           = "audio-worker-deploy"
input_bucket_name  = "my-project-audio-in"
output_bucket_name = "my-project-audio-out"

# EC2-compatible credentials from: openstack ec2 credentials create
# s3_access_key = "..."
# s3_secret_key = "..."

# webhook_url = "https://example.com/hooks/audio-done"
# enable_transcription = true
# worker_flavor = "c2a.large"
cloud-init/worker.yamlYAML
#cloud-config
package_update: true
write_files:
  - path: /etc/audio-worker.env
    owner: root:root
    permissions: "0600"
    content: |
      AWS_ACCESS_KEY_ID=${s3_access_key}
      AWS_SECRET_ACCESS_KEY=${s3_secret_key}
      AWS_DEFAULT_REGION=${s3_region}
      AWS_ENDPOINT_URL_S3=${s3_endpoint}
      INPUT_BUCKET=${input_bucket}
      OUTPUT_BUCKET=${output_bucket}
      WEBHOOK_URL=${webhook_url}
      ENABLE_TRANSCRIPTION=${enable_transcription}
      POLL_INTERVAL=${poll_interval_seconds}
      TARGET_LUFS=${target_lufs}
  - path: /usr/local/bin/audio-worker.sh
    owner: root:root
    permissions: "0755"
    content: |
      #!/bin/bash
      set -euo pipefail
      source /etc/audio-worker.env
      WORK_DIR=/var/lib/audio-worker
      PROCESSED="$WORK_DIR/processed.keys"
      mkdir -p "$WORK_DIR"
      touch "$PROCESSED"
      while true; do
        mapfile -t keys < <(aws s3 ls "s3://$INPUT_BUCKET/" --recursive | awk '{print $4}' | grep -E '\.(wav|flac|mp3|m4a|aac|ogg)$' || true)
        for key in "$${keys[@]}"; do
          [ -z "$key" ] && continue
          grep -Fxq "$key" "$PROCESSED" && continue
          base=$(basename "$key")
          stem="$${base%.*}"
          infile="$WORK_DIR/in-$base"
          outfile="$WORK_DIR/out-$base"
          aws s3 cp "s3://$INPUT_BUCKET/$key" "$infile"
          ffmpeg -hide_banner -loglevel error -i "$infile" \
            -af "loudnorm=I=$TARGET_LUFS:TP=-1.5:LRA=11,afftdn=nf=-25" \
            -c:a libmp3lame -q:a 2 "$outfile"
          aws s3 cp "$outfile" "s3://$OUTPUT_BUCKET/$${stem}-master.mp3"
          if [ "$ENABLE_TRANSCRIPTION" = "true" ]; then
            whisper "$infile" --model tiny --output_format srt --output_dir "$WORK_DIR"
            aws s3 cp "$WORK_DIR/$${stem}.srt" "s3://$OUTPUT_BUCKET/$${stem}.srt" || true
          fi
          if [ -n "$WEBHOOK_URL" ]; then
            curl -fsS -X POST -H 'Content-Type: application/json' \
              -d "{\"source\":\"$key\",\"output\":\"$${stem}-master.mp3\"}" "$WEBHOOK_URL" || true
          fi
          echo "$key" >> "$PROCESSED"
          rm -f "$infile" "$outfile"
        done
        sleep "$POLL_INTERVAL"
      done
  - path: /etc/systemd/system/audio-worker.service
    owner: root:root
    permissions: "0644"
    content: |
      [Unit]
      Description=Audio post-production worker
      After=network-online.target
      Wants=network-online.target

      [Service]
      Type=simple
      EnvironmentFile=/etc/audio-worker.env
      ExecStart=/usr/local/bin/audio-worker.sh
      Restart=always
      RestartSec=10

      [Install]
      WantedBy=multi-user.target
runcmd:
  - |
    set -e
    export DEBIAN_FRONTEND=noninteractive
    apt-get update
    apt-get install -y ffmpeg curl python3-pip
    pip3 install --break-system-packages awscli
    if [ "${enable_transcription}" = "true" ]; then
      apt-get install -y whisper || pip3 install --break-system-packages openai-whisper
    fi
    systemctl daemon-reload
    systemctl enable audio-worker.service
    systemctl start audio-worker.service
README.mdMarkdown
# Audio post-production worker

CPU-only Compute worker that polls an Object Storage input bucket, loudness-normalizes audio with FFmpeg, writes mastered files to an output bucket, and optionally emits a webhook or whisper.cpp transcripts.


**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.

## Prerequisites

- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI OpenStack credentials (`source openrc.sh`)
- EC2-compatible Object Storage credentials. See [Credential bootstrap](#credential-bootstrap)

## Honesty note

This template runs customer-owned scripts on a VM. Quake AI does not provide managed media processing or event delivery. The polling loop is a simple Object Storage list/copy pattern, not a managed queue service. No GPU is allocated.

## Credential bootstrap

Export EC2-compat credentials before `tofu plan`:

```bash
openstack ec2 credentials create
export AWS_ACCESS_KEY_ID=<access>
export AWS_SECRET_ACCESS_KEY=<secret>
export AWS_ENDPOINT_URL_S3=https://object.us-east-1.rumble.cloud
```

Pass the same access and secret values as `s3_access_key` and `s3_secret_key` in `terraform.tfvars` so cloud-init can configure the worker.

## Usage

1. Copy this directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in bucket names and credentials
3. `tofu init`
4. `tofu plan`
5. `tofu apply`

## Variables

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key_name` | string | yes | n/a | Existing SSH keypair name in the project for compute instances |
| `s3_access_key` | string | yes | n/a | EC2-compat access key (sensitive) |
| `s3_secret_key` | string | yes | n/a | EC2-compat secret key (sensitive) |
| `input_bucket_name` | string | yes | n/a | Raw audio bucket |
| `output_bucket_name` | string | yes | n/a | Mastered output bucket |
| `worker_flavor` | string | no | `c2a.large` | CPU flavor for FFmpeg |
| `webhook_url` | string | no | `""` | Optional completion webhook |
| `enable_transcription` | bool | no | `false` | Optional whisper.cpp pass |
| `poll_interval_seconds` | number | no | `60` | Poll interval |
| `target_lufs` | number | no | `-14` | Loudness target |

## Floating IPs

This template allocates **0** floating IPs by default. The worker reaches Object Storage over egress through the private network router.

## Documentation

Full reference: [Audio worker template](/docs/automation/templates/audio-worker)

Tutorial: [Deploy the audio worker template](/resources/tutorials/deploy-audio-worker-template) (companion tutorial prompt `20260603-212111-tutorial-audio-worker`)
Resources, parameters, and variables
Provisions
Parameterized by
Variables
  • key_namerequired
  • s3_access_keyrequired
  • s3_secret_keyrequired
  • input_bucket_namerequired
  • output_bucket_namerequired
  • worker_flavor="c2a.large"
  • image_name="Ubuntu-24.04"
  • external_network="PublicStatic"
  • private_cidr="192.168.70.0/24"
  • instance_name="audio-worker"
  • s3_endpoint="https://object.us-east-1.rumble.cloud"
  • s3_region="us-east-1"
  • webhook_url=""
  • enable_transcription=false
  • poll_interval_seconds=60
  • target_lufs=-14

See also#

Usage Guidelines

The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.

For the full policy, see Usage Guidelines.

Last validated: 07.07.2026

Was this page helpful?