Tags: python fastapi docker github actions scaleway
Use this project only for content you have permission to download and convert.
This app has one frontend and three backend endpoints:
POST /api/convert accepts a YouTube URL and returns a job_id immediately.GET /api/status/{job_id} returns pending | processing | done | error.GET /api/download/{job_id} streams the final MP3 once conversion is complete.The frontend does not hold conversion state itself. It only stores the job_id and polls the status endpoint until the backend marks the job as done.
Before writing even a single line of code, let's think through the APIs responsible for the application's logic.
I usually refer to Zalando guidelines when designing APIs.
I chose FastAPI for the server code. It is an ASGI framework (Asynchronous Server Gateway Interface), which means it can handle many requests concurrently using Python's asyncio event loop. By contrast, traditional WSGI setups process one request per worker thread/process until the response is sent.
Another reason for choosing a Python framework is to use yt-dlp for downloading and ffmpeg for conversion.
We start by creating a simple application. Open a terminal and create folders like this:
mkdir -p youtubeMp3Converter/{routers,schemas,services,static}
Next, create files like this:
cd youtubeMp3Converter
youtubeMp3Converter/
├── main.py # App entry point
├── routers/
│ └── convert.py # HTTP routes and request handling
├── schemas/
│ └── convert.py # Pydantic models for request and response
├── services/
│ └── converter.py # Business logic: yt-dlp, ffmpeg, file management
└── static/
└── index.html # Frontend UI served as static site
main.py creates the FastAPI app instance, registers routers, and mounts the static file server. It is intentionally thin — no business logic lives here.
One important constraint: include_router must come before app.mount. FastAPI evaluates routes in registration order. If StaticFiles is mounted first, its catch-all handler intercepts every request — including /api/* — before the API router sees them.
routers/convert.py contains only HTTP concerns: parsing request bodies, validating input, raising HTTPException, and returning response models. It knows nothing about how conversion actually works.
BackgroundTasks is FastAPI's built-in mechanism for running work after the response is sent. The conversion starts, the response returns immediately with the job_id, and the download happens in a thread pool in the background.
This is a good fit for lightweight jobs in a single service. For heavier or longer-running jobs, use an external queue (for example Celery/RQ/Arq with Redis) so work survives process restarts and can be retried.
schemas/convert.py defines Pydantic models — Python dataclasses with built-in validation, serialization, and JSON schema generation.
services/converter.py is the only place that knows about yt-dlp and ffmpeg. If we ever swap yt-dlp for another library, only this file changes.
The jobs dictionary and /tmp/ootob files are local to one container instance. That is fine for a demo, but it has two implications in production:
For production reliability, store state and artifacts outside the process:
FastAPI generates an OpenAPI specification automatically from your Pydantic schemas and route decorators. No extra work required.
Once the server is running:
http://localhost:8080/docs — Swagger UI: interactive documentation where you can call every endpoint directly from the browserhttp://localhost:8080/redoc — ReDoc: clean, readable reference documentationhttp://localhost:8080/openapi.json — the raw OpenAPI JSON schemaThis is why we invested in Pydantic models for every request and response. The models do double duty: runtime validation and documentation generation. If you add a field to StatusResponse, it appears in Swagger automatically.
The UI is a single static/index.html file — HTML, CSS, and JavaScript, no framework, no build step. FastAPI's StaticFiles serves it at GET /. The JavaScript polls GET /api/status/{job_id} every two seconds using setInterval.
The UI has four explicit states — IDLE, CONVERTING, READY, ERROR — each showing and hiding the relevant cards. Theme (light/dark) is toggled via a CSS data-theme attribute on <html> and persisted to localStorage.
A Makefile is a portable task runner. Every engineer on the project runs the same commands regardless of their local setup.
make test produces a coverage table in the terminal that shows exactly which lines are not covered:
Name Stmts Miss Cover Missing
-----------------------------------------------------
main.py 6 0 100%
routers/convert.py 43 2 95% 51, 58
schemas/convert.py 10 0 100%
services/converter.py 14 10 29% 9-33
-----------------------------------------------------
TOTAL 164 12 93%
services/converter.py sits at 29% — intentionally. The real yt-dlp + ffmpeg code is mocked in tests. You would need an actual YouTube download to hit those lines, which is not appropriate for a fast, offline test suite.
The Dockerfile is the answer to "it works on my machine". It packages Python, ffmpeg, your dependencies, and your code into a single artifact that runs identically everywhere.
Layer ordering matters. Docker caches layers. If you copy source code before installing dependencies, any code change invalidates the dependency cache — meaning uv sync re-runs on every build even when pyproject.toml did not change. By copying pyproject.toml and uv.lock first and running uv sync before copying source, dependency installation is cached as long as the lockfile is unchanged.
--frozen tells uv to install exactly the versions in uv.lock — no resolution, no version drift. Production builds should always pin exact versions.
${PORT:-8080} is a shell default: if the PORT environment variable is set (Render and Scaleway both inject it), use that. Otherwise fall back to 8080. This makes the container portable across hosting platforms without changing the Dockerfile.
GHCR (GitHub Container Registry) is GitHub's hosted registry for Docker images — the same concept as AWS ECR or Docker Hub, but integrated directly into your GitHub account. It is free for public repositories and included in most GitHub plans for private ones.
When you push code to GitHub, your source lives at github.com/username/repo. When you push a Docker image to GHCR, it lives at ghcr.io/username/image. The registry stores the image layers and a manifest, so any server with Docker can pull and run your image without needing your source code at all.
.github/workflows/push_image.yml:
What each step does:
GITHUB_TOKEN, a secret that GitHub automatically injects into every workflow run. No manual secret setup needed.docker build and docker push in one step. The resulting image is tagged ghcr.io/username/ootobmp3:latest.Every git push to main now automatically builds and publishes a fresh image. Your CI pipeline is: code review → merge → image published → ready to deploy.
Developer pushes code to main
↓
GitHub Actions runner spins up (Ubuntu VM, free)
↓
docker build -t ghcr.io/username/ootobmp3:latest .
↓
docker push ghcr.io/username/ootobmp3:latest
↓
Image is stored at ghcr.io
↓
Any server can now: docker pull ghcr.io/username/ootobmp3:latest
Serverless containers sit between a raw VPS and a fully managed PaaS. You provide a Docker image. The platform handles:
You do not manage a server. You do not SSH anywhere. You point at an image and the platform runs it.
Scaleway's Serverless Containers product is organised around namespaces (a logical grouping, like a project or environment) and containers (the actual running unit within a namespace).
By default, GHCR images are private. Scaleway needs credentials to pull them.
Go to GitHub → Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens. Create a token with read:packages scope. Copy it — you will use it in the next step.
Alternatively, you can make the GHCR package public: go to your GitHub profile → Packages → select the image → Package Settings → Change visibility → Public. Public images need no credentials to pull.
In the Scaleway console, navigate to Serverless → Containers → Create namespace.
Choose:
ootobmp3
fr-par, Amsterdam nl-ams, Warsaw pl-waw)If your GHCR image is private, add registry credentials here:
ghcr.io
Via the Scaleway CLI:
# Install CLI
brew install scaleway/tap/scw
# Authenticate
scw init
# Create namespace
scw container namespace create name=ootobmp3 region=fr-par
Note the namespace-id from the output — you need it in the next step.
In the console, inside your namespace, click Create Container.
| Field | Value |
|---|---|
| Name | ootobmp3 |
| Image URL | ghcr.io/yourusername/ootobmp3:latest |
| Port | 8080 |
| Min scale |
0 (scales to zero, no idle cost) |
| Max scale | 5 |
| Memory | 512 MB |
Via CLI:
scw container container create \
namespace-id=<YOUR_NAMESPACE_ID> \
name=ootobmp3 \
registry-image=ghcr.io/yourusername/ootobmp3:latest \
port=8080 \
min-scale=0 \
max-scale=5 \
memory-limit=512 \
region=fr-par
Click Deploy in the console. Scaleway pulls your image from GHCR, runs it, and gives you an endpoint:
https://ootobmp3-<random>.containers.fnc.fr-par.scw.cloud
Your app is live. Open that URL — you will see the OotobMp3 UI.
Because the GitHub Actions workflow pushes ghcr.io/username/ootobmp3:latest on every merge to main, a redeploy just means telling Scaleway to pull the new latest image:
scw container container deploy <CONTAINER_ID> region=fr-par
Or trigger it from the Scaleway console by clicking Redeploy.
For stronger release traceability, prefer immutable tags (for example commit SHA tags) in addition to latest, then deploy a specific tag.
Code change
↓
git push to main
↓
GitHub Actions builds Docker image
↓
Image pushed to ghcr.io/username/ootobmp3:latest
↓
scw container container deploy (or click Redeploy)
↓
Scaleway pulls new image from GHCR
↓
New version is live