Self-Hosted Whisper: A Speech-to-Text API with Ruby and Kubernetes
How to run OpenAI's Whisper as a self-hosted speech-to-text REST API with Docker, a Ruby client, and a Kubernetes deployment for transcribing audio.
We produce English language learning material, and every audio file needs a transcript. Manual transcription is slow and error-prone, and sending thousands of files to a paid API adds up. So we self-hosted OpenAI’s Whisper model behind a small REST API and wired it into our authoring tools.
Whisper earns its reputation. It is accurate across accents and background noise, and the transcripts are detailed enough to use directly in educational content.
Wrapping Whisper in a REST API
Whisper is a Python model; our tooling is Ruby. The simplest boundary is HTTP. We run a Flask wrapper in Docker:
1
2
3
4
5
6
7
8
9
10
11
# Clone the repository
git clone https://github.com/reallyenglish-global/whisper-api-flask
# Build the Docker image
docker build . -t whisper
# Run the service
docker run -p 9000:5000 -e MODEL=small -d whisper
# Test the API
curl -F "file=@your_audio_file.mp3" http://0.0.0.0:9000/whisper
The MODEL variable selects the Whisper model size. Smaller models are faster and cheaper to host; larger ones are more accurate. small has been a reasonable middle ground for clear speech.
A Ruby client
The client posts a multipart file upload and pulls the transcript out of the JSON response:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# frozen_string_literal: true
require 'faraday'
require 'faraday/multipart'
class SpeechToText
class ClientError < StandardError; end
class ServerError < StandardError; end
def self.enabled?
ENV.fetch('WHISPER_ENDPOINT', '').present?
end
def convert(audio)
audio = File.new(audio) if !audio.is_a?(File) && File.file?(audio)
response = conn.post(endpoint, payload(audio.path))
raise ServerError, "Error: #{response.status} - #{response.body}" unless response.success?
results = response.body[:results]
raise ClientError, 'No results found' if results.blank?
results[0][:transcript]
end
private
def conn
@conn ||= Faraday.new do |f|
f.request :multipart
f.adapter :net_http
f.headers['Content-Type'] = 'multipart/form-data'
f.response :json, parser_options: { symbolize_names: true }
end
end
def endpoint
@endpoint ||= ENV.fetch('WHISPER_ENDPOINT', '')
end
def payload(file_path)
{
file: Faraday::Multipart::FilePart.new(file_path, 'audio/mp3'),
response_format: 'verbose_json'
}
end
end
Usage from the application side:
1
2
service = SpeechToText.new
transcript = service.convert('path/to/audio_file.mp3')
The enabled? check matters in practice: environments without a Whisper endpoint fall back to skipping transcription rather than failing.
Deploying to Kubernetes
For production we run the API in our cluster:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
apiVersion: apps/v1
kind: Deployment
metadata:
name: whisper
namespace: speech
labels:
app: whisper
spec:
replicas: 1
selector:
matchLabels:
app: whisper
template:
metadata:
labels:
app: whisper
spec:
containers:
- name: whisper
image: ghcr.io/reallyenglish-global/whisper-api-flask
imagePullPolicy: Always
ports:
- containerPort: 5000
env:
- name: MODEL
value: base
readinessProbe:
httpGet:
path: /
port: 5000
1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: whisper
namespace: speech
spec:
selector:
app: whisper
ports:
- port: 5000
targetPort: 5000
Trade-offs to know before you copy this
- On CPU, transcription is slow. GPU acceleration changes the economics; without it, budget minutes per file, not seconds.
- The model holds a lot of memory. That is the real hosting cost, and it is why we run the
basemodel in the cluster andsmallonly where we can afford it. - The API above has no authentication. Inside a cluster that is acceptable; anywhere else, put auth in front of it before someone else’s audio bill becomes yours.
- Handle failure explicitly. Network errors, invalid audio, and empty results all happen, which is why the client raises typed errors instead of returning nil.
Self-hosting Whisper turned transcription from a manual chore into a call in our authoring pipeline. The model keeps improving upstream, so it is worth tracking releases: a model bump has so far been the cheapest accuracy improvement available.