At some point in every engineer's life, they sit down and think: "You know what this fine-tuning pipeline needs? More YAML." That moment arrived for me, and the result is nebius-actions — a set of composable GitHub Actions that let you train, deploy, and smoke-test a model on Nebius AI Cloud GPU infrastructure, entirely from a GitHub workflow.
The Idea
I wanted one thing: click a button in GitHub, have a real GPU spin up, fine-tune a model, package it into a serving image, deploy it to a live endpoint, prove it works, and clean everything up — with zero manual steps and no leaked cloud resources. No Jupyter notebooks saved as "final_v3_REAL.ipynb". No SSH-ing into a VM to babysit training. Just a workflow that does the whole thing and gets out of your way.
The result is demo-run-job.yml: it QLoRA-fine-tunes Qwen2.5-0.5B on wikitext for 30 steps with Axolotl, bakes the LoRA adapters into a vLLM serving image, deploys it to a Nebius Endpoint, and smoke-tests it — all triggered by a single workflow_dispatch click.
The pipeline runs across five GitHub jobs: submit, wait, deploy, try, and cleanup. State that must cross job boundaries — bucket name, image ref, job ID, endpoint URL — travels as job outputs.
The Five Acts of This Drama
Act 1 — submit: Everything Happens Here
The submit job is where the magic (and most of the YAML) lives. It:
- Writes an Axolotl config inline — the workflow is the single source of truth. No external config file to lose.
- Writes a
train-and-push.shscript inline — yes, a full Bash script, heredoc'd into the workflow. It fine-tunes with Axolotl, packages the adapters into a tar layer, installscrane(a daemonless container registry client, because the GPU job has no Docker daemon), and pushesvllm/vllm-openai:latest+ the adapter layer to the Nebius Container Registry. - Provisions a fresh S3 bucket per run, uploads both files, and creates the Nebius Job.
The auth story is pleasantly clean: a setup action configures a key-based CLI profile, and an auth action mints a short-lived NEBIUS_IAM_TOKEN for the SDK path. No long-lived credentials sitting in your repo — just a token that expires before you finish reading this post.
- name: Submit fine-tune + image push
id: submit
uses: ./actions/submit-job
with:
name: demo-axolotl-${{ github.run_id }}
image: ${{ vars.NEBIUS_JOB_IMAGE || 'docker.io/axolotlai/axolotl:main-20260309-py3.11-cu128-2.9.1' }}
platform: ${{ inputs.platform }}
preset: ${{ inputs.preset }}
command: bash
timeout: 1h
env: |
BASE_IMAGE=${{ vars.NEBIUS_VLLM_IMAGE || 'docker.io/vllm/vllm-openai:latest' }}
IMAGE=${{ steps.image.outputs.image }}
REGISTRY=${{ steps.image.outputs.registry }}
REGISTRY_TOKEN=${{ env.NEBIUS_IAM_TOKEN }}
mounts: ${{ steps.bucket.outputs.bucket-id }}:/workspace/data:rw
disk-size: ${{ inputs.disk-size }}
preemptible: true
args: -c "bash /workspace/data/train-and-push.sh"
Act 2 — wait: The Anxious Polling Phase
This job does one thing: stream the GPU job's logs and poll it to a terminal state. It also handles a subtle but expensive problem — if you cancel the GitHub workflow, the Nebius Job keeps running (and keeps billing you) unless you explicitly forward the cancellation:
- name: Wait for fine-tune + image push
id: wait
uses: ./actions/wait-for-job
with:
job-id: ${{ needs.submit.outputs.job-id }}
stream-logs: true
poll-interval: 10
timeout: 3600
- name: Cancel job on workflow cancellation
if: cancelled() && needs.submit.outputs.job-id != ''
uses: ./actions/cancel-job
with:
job-id: ${{ needs.submit.outputs.job-id }}
That if: cancelled() guard has saved me from some spicy cloud bills.
Act 3 — deploy: It Lives
Once wait confirms the job succeeded — meaning the vLLM image with baked-in adapters is in the registry — deploy creates a Nebius Endpoint and polls it to serving. wait: false is intentional: it means the endpoint ID is published even if the endpoint never becomes ready, which lets cleanup tear it down safely regardless.
- name: Deploy endpoint
id: deploy
uses: ./actions/deploy-endpoint
with:
name: demo-axolotl-${{ github.run_id }}
image: ${{ needs.submit.outputs.image }}
port: 8000
public: true
preset: ${{ inputs.endpoint-preset }}
platform: ${{ inputs.endpoint-platform }}
disk-size: ${{ inputs.endpoint-disk-size }}
project-id: ${{ vars.NEBIUS_PROJECT_ID }}
wait: false
- name: Wait for endpoint
id: endpoint
uses: ./actions/wait-for-endpoint
with:
endpoint-id: ${{ steps.deploy.outputs.endpoint-id }}
poll-interval: 10
timeout: 1800
Act 4 — try: Does It Actually Work?
The smoke test is beautifully minimal:
curl -fsS --retry 30 --retry-delay 10 "$URL/health"
curl -fsS -X POST "$URL/v1/completions" \
-H 'Content-Type: application/json' \
-d '{"model": "demo", "prompt": "The quick brown fox", "max_tokens": 16}'
demo is the --lora-modules name that train-and-push.sh baked into the image entrypoint. You fine-tuned it, you named it, now you serve it. Deeply personal.
Act 5 — cleanup: if: always()
The cleanup job has needs on all four upstream jobs and — critically — if: always(). This is the move that separates people who have been surprised by a $200 cloud bill from people who haven't:
cleanup:
needs: [submit, wait, deploy, try]
if: always()
steps:
- name: Delete endpoint
if: needs.deploy.outputs.endpoint-id != ''
uses: ./actions/delete-endpoint
with:
endpoint-id: ${{ needs.deploy.outputs.endpoint-id }}
- name: Delete bucket
if: needs.submit.outputs.bucket-id != ''
uses: ./actions/delete-bucket
with:
bucket: ${{ needs.submit.outputs.bucket-name }}
bucket-id: ${{ needs.submit.outputs.bucket-id }}
Endpoint dies. Bucket dies. The pushed image stays in the registry, tagged with the run ID, ready to be redeployed. Responsible MLOps.
The Actions Themselves
The reusable building blocks are small and composable. The key ones:
-
setup— installs thenebiusCLI, configures a key-based profile, exportsNEBIUS_PROJECT_IDandNEBIUS_SERVICE_ACCOUNT_IDfor downstream steps. -
auth— mints a short-lived IAM token via OIDC (keyless, recommended) or a service-account key. -
submit-job+wait-for-job— decoupled create and poll, so you can fan out or gate between them. -
deploy-endpoint+wait-for-endpoint+delete-endpoint— the full endpoint lifecycle. -
create-bucket,upload-object,delete-bucket— the storage trio, all using short-lived S3 keys minted on the fly, no stored S3 credentials required.
The design philosophy: one action per resource, no hidden orchestration, no magic. If you want a rigid pipeline, the demo is there. If you want to mix and match — say, fine-tune in one workflow and deploy in another — the primitives are waiting.
Try It Yourself
The repo is at github.com/piotrjanik/nebius-actions. You'll need a Nebius project, a service account with ai.editor role, and the patience to fill in five repo variables and one secret. The README has the exact nebius iam commands to set it all up.
Then click Run workflow, pick your GPU preset, and watch a 500M-parameter model get fine-tuned and deployed while you make coffee. That's the dream. That's the YAML.
Top comments (0)