DEV Community

Piotr Janik
Piotr Janik

Posted on

From git push to Fine-Tuned Model in Production

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:

  1. Writes an Axolotl config inline — the workflow is the single source of truth. No external config file to lose.
  2. Writes a train-and-push.sh script inline — yes, a full Bash script, heredoc'd into the workflow. It fine-tunes with Axolotl, packages the adapters into a tar layer, installs crane (a daemonless container registry client, because the GPU job has no Docker daemon), and pushes vllm/vllm-openai:latest + the adapter layer to the Nebius Container Registry.
  3. 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"
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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 the nebius CLI, configures a key-based profile, exports NEBIUS_PROJECT_ID and NEBIUS_SERVICE_ACCOUNT_ID for 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)