diff --git a/dictionary.txt b/dictionary.txt
index 8af2f3b8e..c5b297db2 100644
--- a/dictionary.txt
+++ b/dictionary.txt
@@ -97,6 +97,9 @@ subcollections
subcollection
supertest
localhost
+renderer
+bpy
+CUDA
http
BYO
TLDR
diff --git a/docs/guides/python/blender-render.mdx b/docs/guides/python/blender-render.mdx
new file mode 100644
index 000000000..d2728008e
--- /dev/null
+++ b/docs/guides/python/blender-render.mdx
@@ -0,0 +1,911 @@
+---
+description: Use the Nitric framework to build a service for rendering Blender scenes using cloud GPUs
+tags:
+ - API
+ - AI & Machine Learning
+image: /docs/images/guides/blender-render/featured.png
+image_alt: 'Render Blender Banner'
+featured:
+ image: /docs/images/guides/blender-render/featured.png
+ image_alt: 'Render Blender featured image'
+languages:
+ - python
+published_at: 2024-11-13
+---
+
+# Use Cloud GPUs for rendering your Blender projects
+
+This example shows how you can create a remote [Blender](https://www.blender.org/) rendering application using Blender's Python interface.
+
+By using the cloud you can render your Blender scenes on infrastructure that scales and with CPU or GPU resources you might not have access to locally.
+
+Here's a final render that was done using cloud GPUs:
+
+
+
+## Prerequisites
+
+This guide may require you have basic knowledge on how Blender works, and the access to or ability to create Blender scenes for testing.
+
+- [UV](https://docs.astral.sh/uv/) - for dependency management
+- The [Nitric CLI](/get-started/installation)
+- An [AWS](https://aws.amazon.com) or [Google Cloud](https://cloud.google.com) account (_your choice_)
+- [Blender](https://www.blender.org/download/) - for creating your Blender scenes
+
+## Getting started
+
+We'll start by creating a new Nitric project.
+
+
+ If you want to take a look at the finished code, it can be found
+ [here](https://github.com/nitrictech/examples/tree/main/v1/blender-render).
+
+
+```bash
+nitric new blender-rendering py-starter
+```
+
+We can then resolve our dependencies.
+
+```bash
+uv sync
+```
+
+We'll also add [bpy](https://docs.blender.org/api/current/info_overview.html#python-in-blender) as an optional dependency. By making it an optional dependency, we can choose to only install it for the containers that require it, reducing the size of the containers. `bpy` is the API that will interact with the Blender environment for setting up and rendering our scenes. The version of `bpy` should match the version of Blender you intend on using.
+
+```bash
+uv add bpy==4.2.0 --optional ml
+```
+
+We'll organize our project structure like so:
+
+```text
++--common/
+| +-- __init__.py
+| +-- resources.py
++--batches/
+| +-- renderer.py
++--services/
+| +-- api.py
++-- blender.dockerfile
++-- blender.dockerignore
++--.gitignore
++--.python-version
++-- pyproject.toml
++-- python.dockerfile
++-- python.dockerignore
++-- nitric.yaml
++-- README.md
+```
+
+## Creating the resources
+
+We'll start by creating a file to define our Nitric resources. For this project, we'll need an API, a batch job, and two buckets: one for the .blend files and another for the resulting renders. The API will interact with the buckets, while the batch job will handle the long-running render tasks.
+
+```python title:common/resources.py
+from nitric.resources import api, job, bucket
+
+main_api = api("main")
+
+renderer_job = job("render-image")
+
+blend_bucket = bucket("blend-files")
+rendered_bucket = bucket("rendered-bucket")
+```
+
+## Routes for our API
+
+Now that we have defined resources, we can import our API and add some routes to access the buckets. Start by importing the resources and adding permissions to the resources.
+
+```python title:services/api.py
+import json
+
+from nitric.application import Nitric
+from nitric.context import HttpContext
+from nitric.resources import BucketNotificationContext
+
+from common.resources import rendered_bucket, main_api, blend_bucket, renderer_job
+
+readable_rendered_bucket = rendered_bucket.allow("read")
+readable_writeable_blend_bucket = blend_bucket.allow("write", "read")
+submittable_renderer_job = renderer_job.allow("submit")
+
+Nitric.run()
+```
+
+We'll then write a route for getting a file from the rendered bucket. These will get a signed download url and redirect the user to this url for downloading the content.
+
+```python title:services/api.py
+# !collapse(1:11) collapsed
+import json
+
+from nitric.application import Nitric
+from nitric.context import HttpContext
+from nitric.resources import BucketNotificationContext
+
+from common.resources import rendered_bucket, main_api, blend_bucket, renderer_job
+
+readable_rendered_bucket = rendered_bucket.allow("read")
+readable_writeable_blend_bucket = blend_bucket.allow("write", "read")
+submittable_renderer_job = renderer_job.allow("submit")
+
+@main_api.get("/render/:file")
+async def get_image(ctx: HttpContext):
+ file_name = ctx.req.params['file']
+
+ download_url = await readable_writeable_blend_bucket.file(file_name).download_url(3600)
+
+ ctx.res.headers["Location"] = download_url
+ ctx.res.status = 303
+
+ return ctx
+
+Nitric.run()
+```
+
+The final route will be for adding a `.blend` file for rendering as well as the render settings for the scene. This will add the contents of the request to a file in the blend bucket by redirecting the request to the upload URL after adding the metadata to the bucket.
+
+```python title:services/api.py
+# !collapse(1:22) collapsed
+import json
+
+from nitric.application import Nitric
+from nitric.context import HttpContext
+from nitric.resources import BucketNotificationContext
+
+from common.resources import rendered_bucket, main_api, blend_bucket, renderer_job
+
+readable_rendered_bucket = rendered_bucket.allow("read")
+readable_writeable_blend_bucket = blend_bucket.allow("write", "read")
+submittable_renderer_job = renderer_job.allow("submit")
+
+@main_api.get("/render/:file")
+async def get_render(ctx: HttpContext):
+ file_name = ctx.req.params['file']
+
+ download_url = await readable_writeable_blend_bucket.file(file_name).download_url(3600)
+
+ ctx.res.headers["Location"] = download_url
+ ctx.res.status = 303
+
+ return ctx
+
+@main_api.put("/:blend")
+async def write_render(ctx: HttpContext):
+ blend_scene_key = ctx.req.params["blend"]
+
+ # Write the blend scene rendering settings
+ raw_metadata = {
+ "file_format": str(ctx.req.query.get('file_format', ['PNG'])[0]),
+ "fps": int(ctx.req.query.get('fps', [0])[0]),
+ "device": str(ctx.req.query.get('device', ['GPU'])[0]),
+ "engine": str(ctx.req.query.get('engine', ['CYCLES'])[0]),
+ "animate": bool(ctx.req.query.get('animate', [False])[0]),
+ }
+ metadata = bytes(json.dumps(raw_metadata), encoding="utf-8")
+
+ await readable_writeable_blend_bucket.file(f"metadata-{blend_scene_key}.json").write(metadata)
+
+ # Write the blend scene to the bucket using an upload URL
+ blend_upload_url = await readable_writeable_blend_bucket.file(f"blend-{blend_scene_key}.blend").upload_url()
+
+ ctx.res.headers["Location"] = blend_upload_url
+ ctx.res.status = 307
+
+ return ctx
+
+Nitric.run()
+```
+
+We will add a storage listener which will be triggered by files being added to the `blend_bucket`. This is so we can trigger the rendering job when the rendering metadata and the `.blend` file are added to the bucket. By making this start from the listener instead of the API, we can set up workflows where rendering could be triggered from adding files to buckets manually.
+
+```python
+# !collapse(1:46) collapsed
+import json
+
+from nitric.application import Nitric
+from nitric.context import HttpContext
+from nitric.resources import BucketNotificationContext
+
+from common.resources import rendered_bucket, main_api, blend_bucket, renderer_job
+
+readable_rendered_bucket = rendered_bucket.allow("read")
+readable_writeable_blend_bucket = blend_bucket.allow("write", "read")
+submittable_renderer_job = renderer_job.allow("submit")
+
+@main_api.get("/render/:file")
+async def get_render(ctx: HttpContext):
+ file_name = ctx.req.params['file']
+
+ download_url = await readable_writeable_blend_bucket.file(file_name).download_url(3600)
+
+ ctx.res.headers["Location"] = download_url
+ ctx.res.status = 303
+
+ return ctx
+
+@main_api.put("/:blend")
+async def write_render(ctx: HttpContext):
+ blend_scene_key = ctx.req.params["blend"]
+
+ # Write the blend scene rendering settings
+ raw_metadata = {
+ "file_format": str(ctx.req.query.get('file_format', ['PNG'])[0]),
+ "fps": int(ctx.req.query.get('fps', [0])[0]),
+ "device": str(ctx.req.query.get('device', ['GPU'])[0]),
+ "engine": str(ctx.req.query.get('engine', ['CYCLES'])[0]),
+ "animate": bool(ctx.req.query.get('animate', [False])[0]),
+ }
+ metadata = bytes(json.dumps(raw_metadata), encoding="utf-8")
+
+ await readable_writeable_blend_bucket.file(f"metadata-{blend_scene_key}.json").write(metadata)
+
+ # Write the blend scene to the bucket using an upload URL
+ blend_upload_url = await readable_writeable_blend_bucket.file(f"blend-{blend_scene_key}.blend").upload_url()
+
+ ctx.res.headers["Location"] = blend_upload_url
+ ctx.res.status = 307
+
+ return ctx
+
+@blend_bucket.on("write", "blend-")
+async def on_written_image(ctx: BucketNotificationContext):
+ key_without_extension = ctx.req.key.split(".")[0][6:]
+
+ await submittable_renderer_job.submit(
+ {
+ "key": key_without_extension,
+ }
+ )
+
+ return ctx
+
+Nitric.run()
+```
+
+## Using bpy for scripted blender rendering
+
+Start by adding our imports and the resources we defined earlier.
+
+```python title:batches/renderer.py
+import os.path
+import json
+import glob
+
+from nitric.context import JobContext
+from nitric.application import Nitric
+from common.resources import rendered_bucket, renderer_job, blend_bucket
+
+readable_blend_bucket = blend_bucket.allow("read")
+writeable_rendered_bucket = rendered_bucket.allow("write")
+
+@renderer_job(cpus=1, memory=1024, gpus=0)
+async def render_image(ctx: JobContext):
+ return ctx
+```
+
+Next, we'll add functionality to render the scene based on the file that is sent in the job context. Start by pointing `bpy` to the blender binary.
+
+```python title:batches/renderer.py
+# !collapse(1:10) collapsed
+import os.path
+import json
+import glob
+
+from nitric.context import JobContext
+from nitric.application import Nitric
+from common.resources import rendered_bucket, renderer_job, blend_bucket
+
+readable_blend_bucket = blend_bucket.allow("read")
+writeable_rendered_bucket = rendered_bucket.allow("write")
+
+@renderer_job(cpus=1, memory=1024, gpus=0)
+async def render_image(ctx: JobContext):
+ import bpy
+
+ blend_key = ctx.req.data["key"]
+
+ # Register the blender binary
+ blender_bin = "blender"
+
+ if os.path.isfile(blender_bin):
+ bpy.app.binary_path = blender_bin
+ else:
+ ctx.res.success = False
+ return ctx
+
+ return ctx
+
+Nitric.run()
+```
+
+Next, we'll read the blend file from the bucket that matches the key sent in the context and write it to a file accessible by the blender renderer. The line `bpy.ops.wm.open_mainfile(filepath="input")` will set the input scene for the renderer.
+
+```python title:batches/renderer.py
+# !collapse(1:10) collapsed
+import os.path
+import json
+import glob
+
+from nitric.context import JobContext
+from nitric.application import Nitric
+from common.resources import rendered_bucket, renderer_job, blend_bucket
+
+readable_blend_bucket = blend_bucket.allow("read")
+writeable_rendered_bucket = rendered_bucket.allow("write")
+
+@renderer_job(cpus=1, memory=1024, gpus=0)
+# !collapse(1:14) collapsed
+async def render_image(ctx: JobContext):
+ import bpy
+
+ blend_key = ctx.req.data["key"]
+
+ # Register the blender binary
+ blender_bin = "blender"
+
+ if os.path.isfile(blender_bin):
+ bpy.app.binary_path = blender_bin
+ else:
+ ctx.res.success = False
+ return ctx
+
+ # load the file from a bucket to a local file
+ blend_file = await readable_blend_bucket.file(f"blend-{blend_key}.blend").read()
+
+ with open("input", "wb") as f:
+ f.write(blend_file)
+
+ bpy.ops.wm.open_mainfile(filepath="input")
+
+ return ctx
+
+
+Nitric.run()
+```
+
+We'll then set the settings for the render engine based on the metadata that was added to the bucket.
+
+```python title:batches/renderer.py
+# !collapse(1:10) collapsed
+import os.path
+import json
+import glob
+
+from nitric.context import JobContext
+from nitric.application import Nitric
+from common.resources import rendered_bucket, renderer_job, blend_bucket
+
+readable_blend_bucket = blend_bucket.allow("read")
+writeable_rendered_bucket = rendered_bucket.allow("write")
+
+@renderer_job(cpus=1, memory=1024, gpus=0)
+# !collapse(1:22) collapsed
+async def render_image(ctx: JobContext):
+ import bpy
+
+ blend_key = ctx.req.data["key"]
+
+ # Register the blender binary
+ blender_bin = "blender"
+
+ if os.path.isfile(blender_bin):
+ bpy.app.binary_path = blender_bin
+ else:
+ ctx.res.success = False
+ return ctx
+
+ # load the file from a bucket to a local file
+ blend_file = await readable_blend_bucket.file(f"blend-{blend_key}.blend").read()
+
+ with open("input", "wb") as f:
+ f.write(blend_file)
+
+ bpy.ops.wm.open_mainfile(filepath="input")
+
+ raw_metadata = await readable_blend_bucket.file(f"{blend_key}.metadata.json").read()
+
+ metadata = json.loads(raw_metadata)
+
+ bpy.context.scene.render.filepath = blend_key
+ bpy.context.scene.render.engine = metadata.get('engine')
+ bpy.context.scene.cycles.device = metadata.get('device')
+ bpy.context.scene.render.image_settings.file_format = metadata.get('file_format')
+ bpy.context.scene.render.fps = metadata.get('fps')
+
+ return ctx
+
+
+Nitric.run()
+```
+
+The next step, is rendering depending on whether the requested scene needs to be animated or not.
+
+```python title:batches/renderer.py
+# !collapse(1:10) collapsed
+import os.path
+import json
+import glob
+
+from nitric.context import JobContext
+from nitric.application import Nitric
+from common.resources import rendered_bucket, renderer_job, blend_bucket
+
+readable_blend_bucket = blend_bucket.allow("read")
+writeable_rendered_bucket = rendered_bucket.allow("write")
+
+@renderer_job(cpus=1, memory=1024, gpus=0)
+# !collapse(1:32) collapsed
+async def render_image(ctx: JobContext):
+ import bpy
+
+ blend_key = ctx.req.data["key"]
+
+ # Register the blender binary
+ blender_bin = "blender"
+
+ if os.path.isfile(blender_bin):
+ bpy.app.binary_path = blender_bin
+ else:
+ ctx.res.success = False
+ return ctx
+
+ # load the file from a bucket to a local file
+ blend_file = await readable_blend_bucket.file(f"blend-{blend_key}.blend").read()
+
+ with open("input", "wb") as f:
+ f.write(blend_file)
+
+ bpy.ops.wm.open_mainfile(filepath="input")
+
+ raw_metadata = await readable_blend_bucket.file(f"{blend_key}.metadata.json").read()
+
+ metadata = json.loads(raw_metadata)
+
+ bpy.context.scene.render.filepath = blend_key
+ bpy.context.scene.render.engine = metadata.get('engine')
+ bpy.context.scene.cycles.device = metadata.get('device')
+ bpy.context.scene.render.image_settings.file_format = metadata.get('file_format')
+ bpy.context.scene.render.fps = metadata.get('fps')
+
+ if metadata.get('animate'):
+ bpy.ops.render.render(animation=True)
+ else:
+ bpy.ops.render.render(write_still=True)
+
+ return ctx
+
+
+Nitric.run()
+```
+
+With the rendering complete, we'll read the contents of the outputted render and add it to the bucket. We use a glob pattern to find the outputted file using the blend file key as the prefix.
+
+```python title:batches/renderer.py
+# !collapse(1:10) collapsed
+import os.path
+import json
+import glob
+
+from nitric.context import JobContext
+from nitric.application import Nitric
+from common.resources import rendered_bucket, renderer_job, blend_bucket
+
+readable_blend_bucket = blend_bucket.allow("read")
+writeable_rendered_bucket = rendered_bucket.allow("write")
+
+@renderer_job(cpus=1, memory=1024, gpus=0)
+# !collapse(1:37) collapsed
+async def render_image(ctx: JobContext):
+ import bpy
+
+ blend_key = ctx.req.data["key"]
+
+ # Register the blender binary
+ blender_bin = "blender"
+
+ if os.path.isfile(blender_bin):
+ bpy.app.binary_path = blender_bin
+ else:
+ ctx.res.success = False
+ return ctx
+
+ # load the file from a bucket to a local file
+ blend_file = await readable_blend_bucket.file(f"blend-{blend_key}.blend").read()
+
+ with open("input", "wb") as f:
+ f.write(blend_file)
+
+ bpy.ops.wm.open_mainfile(filepath="input")
+
+ raw_metadata = await readable_blend_bucket.file(f"{blend_key}.metadata.json").read()
+
+ metadata = json.loads(raw_metadata)
+
+ bpy.context.scene.render.filepath = blend_key
+ bpy.context.scene.render.engine = metadata.get('engine')
+ bpy.context.scene.cycles.device = metadata.get('device')
+ bpy.context.scene.render.image_settings.file_format = metadata.get('file_format')
+ bpy.context.scene.render.fps = metadata.get('fps')
+
+ if metadata.get('animate'):
+ bpy.ops.render.render(animation=True)
+ else:
+ bpy.ops.render.render(write_still=True)
+
+ file_name = glob.glob(f"{blend_key}*")[0]
+ with open(file_name, "rb") as f:
+ image_bytes = f.read()
+
+ await writeable_rendered_bucket.file(file_name).write(image_bytes)
+
+ return ctx
+
+
+Nitric.run()
+```
+
+## Creating GPU enabled dockerfiles
+
+With our code complete, we can write a dockerfile that our batch job will run in. Start with the base image that copies our application code and resolves the dependencies using `uv`.
+
+```dockerfile title:blender.dockerfile
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm AS builder
+
+ARG HANDLER
+ENV HANDLER=${HANDLER}
+
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONPATH=.
+
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen -v --no-install-project --extra ml --no-dev --no-python-downloads
+
+COPY . /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen -v --no-dev --extra ml --no-python-downloads
+```
+
+The next stage is another image with the base image of `nvidia/cuda` which will enable CUDA support with the render engine. We'll set some environment variables to enable GPU use and download some apt dependencies that blender requires.
+
+```dockerfile title:blender.dockerfile
+# !collapse(1:18) collapsed
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm AS builder
+
+ARG HANDLER
+ENV HANDLER=${HANDLER}
+
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONPATH=.
+
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen -v --no-install-project --extra ml --no-dev --no-python-downloads
+
+COPY . /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen -v --no-dev --extra ml --no-python-downloads
+
+FROM nvidia/cuda:12.6.2-cudnn-runtime-ubuntu24.04
+
+ENV NVIDIA_DRIVER_CAPABILITIES=all
+ENV NVIDIA_REQUIRE_CUDA="cuda>=8.0"
+
+RUN --mount=type=cache,target=/var/cache/apt/archives \
+ apt-get update && apt-get install -y \
+ software-properties-common \
+ build-essential \
+ libxi6 \
+ libglu1-mesa \
+ libgl1 \
+ libglx-mesa0 \
+ libxxf86vm1 \
+ libxkbcommon0 \
+ libsm6 \
+ libxext6 \
+ libxrender1 \
+ libxrandr2 \
+ libx11-6 \
+ xorg \
+ libxkbcommon0 \
+ ffmpeg \
+ wget \
+ curl \
+ ca-certificates && \
+ # Add python 3.11
+ add-apt-repository ppa:deadsnakes/ppa && \
+ apt-get install -y python3.11 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python3.11 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python3 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python
+```
+
+We'll then download blender using the `ADD` command, downloading and extracting the file into the `/app` directory so it can be used by our job application.
+
+```dockerfile title:blender.dockerfile
+# !collapse(1:51) collapsed
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm AS builder
+
+ARG HANDLER
+ENV HANDLER=${HANDLER}
+
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONPATH=.
+
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen -v --no-install-project --extra ml --no-dev --no-python-downloads
+
+COPY . /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen -v --no-dev --extra ml --no-python-downloads
+
+FROM nvidia/cuda:12.6.2-cudnn-runtime-ubuntu24.04
+
+ENV NVIDIA_DRIVER_CAPABILITIES=all
+ENV NVIDIA_REQUIRE_CUDA="cuda>=8.0"
+
+RUN --mount=type=cache,target=/var/cache/apt/archives \
+ apt-get update && apt-get install -y \
+ software-properties-common \
+ build-essential \
+ libxi6 \
+ libglu1-mesa \
+ libgl1 \
+ libglx-mesa0 \
+ libxxf86vm1 \
+ libxkbcommon0 \
+ libsm6 \
+ libxext6 \
+ libxrender1 \
+ libxrandr2 \
+ libx11-6 \
+ xorg \
+ libxkbcommon0 \
+ ffmpeg \
+ wget \
+ curl \
+ ca-certificates && \
+ add-apt-repository ppa:deadsnakes/ppa && \
+ apt-get install -y python3.11 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python3.11 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python3 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python
+
+ # Blender variables used for specifying the blender version
+ ARG BLENDER_OS="linux-x64"
+ ARG BL_VERSION_SHORT="4.2"
+ ARG BL_VERSION_FULL="4.2.2"
+ ARG BL_DL_ROOT_URL="https://mirrors.ocf.berkeley.edu/blender/release"
+ ARG BLENDER_DL_URL=${BL_DL_ROOT_URL}/Blender${BL_VERSION_SHORT}/blender-${BL_VERSION_FULL}-${BLENDER_OS}.tar.xz
+
+ WORKDIR /app
+
+ # Download and unpack Blender
+ ADD $BLENDER_DL_URL blender
+```
+
+Finally, we'll make sure we add our code from the base image and set the entrypoint as the python code.
+
+```dockerfile title:blender.dockerfile
+# !collapse(1:63) collapsed
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm AS builder
+
+ARG HANDLER
+ENV HANDLER=${HANDLER}
+
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONPATH=.
+
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen -v --no-install-project --extra ml --no-dev --no-python-downloads
+
+COPY . /app
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen -v --no-dev --extra ml --no-python-downloads
+
+FROM nvidia/cuda:12.6.2-cudnn-runtime-ubuntu24.04
+
+ENV NVIDIA_DRIVER_CAPABILITIES=all
+ENV NVIDIA_REQUIRE_CUDA="cuda>=8.0"
+
+RUN --mount=type=cache,target=/var/cache/apt/archives \
+ apt-get update && apt-get install -y \
+ software-properties-common \
+ build-essential \
+ libxi6 \
+ libglu1-mesa \
+ libgl1 \
+ libglx-mesa0 \
+ libxxf86vm1 \
+ libxkbcommon0 \
+ libsm6 \
+ libxext6 \
+ libxrender1 \
+ libxrandr2 \
+ libx11-6 \
+ xorg \
+ libxkbcommon0 \
+ ffmpeg \
+ wget \
+ curl \
+ ca-certificates && \
+ add-apt-repository ppa:deadsnakes/ppa && \
+ apt-get install -y python3.11 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python3.11 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python3 && \
+ ln -sf /usr/bin/python3.11 /usr/local/bin/python
+
+# Blender variables used for specifying the blender version
+ARG BLENDER_OS="linux-x64"
+ARG BL_VERSION_SHORT="4.2"
+ARG BL_VERSION_FULL="4.2.2"
+ARG BL_DL_ROOT_URL="https://mirrors.ocf.berkeley.edu/blender/release"
+ARG BLENDER_DL_URL=${BL_DL_ROOT_URL}/Blender${BL_VERSION_SHORT}/blender-${BL_VERSION_FULL}-${BLENDER_OS}.tar.xz
+
+WORKDIR /app
+
+# Download and unpack Blender
+ADD $BLENDER_DL_URL blender
+
+ARG HANDLER
+
+ENV HANDLER=${HANDLER}
+ENV PYTHONUNBUFFERED=TRUE
+ENV PYTHONPATH="."
+
+# Copy the application from the builder
+COPY --from=builder --chown=app:app /app /app
+
+# Place executables in the environment at the front of the path
+ENV PATH="/app/.venv/bin:$PATH"
+
+# Run the service using the path to the handler
+ENTRYPOINT python -u $HANDLER
+```
+
+Add two dockerignore files to help optimize the size of the image. These will be `python.dockerignore` and `blender.dockerignore`.
+
+```text
+.mypy_cache/
+.nitric/
+.venv/
+nitric-spec.json
+nitric.yaml
+README.md
+```
+
+We can update the `nitric.yaml` file to allow our services and batch jobs to use the custom docker runtime we set up and point to the services directly.
+
+```yaml title:nitric.yaml
+name: blender-render
+services:
+ - match: services/*.py
+ start: uv run watchmedo auto-restart -p *.py --no-restart-on-command-exit -R python -- -u $SERVICE_PATH
+ runtime: python
+
+batch-services:
+ - match: batches/*.py
+ start: uv run watchmedo auto-restart -p *.py --no-restart-on-command-exit -R python -- -u $SERVICE_PATH
+ runtime: blender
+
+runtimes:
+ blender:
+ dockerfile: blender.dockerfile
+ python:
+ dockerfile: python.dockerfile
+```
+
+We'll also need to add `batch-services` as a preview feature.
+
+```yaml title:nitric.yaml
+# !collapse(1:17) collapsed
+name: blender-render
+services:
+ - match: services/*.py
+ start: uv run watchmedo auto-restart -p *.py --no-restart-on-command-exit -R python -- -u $SERVICE_PATH
+ runtime: python
+
+batch-services:
+ - match: batches/*.py
+ start: uv run watchmedo auto-restart -p *.py --no-restart-on-command-exit -R python -- -u $SERVICE_PATH
+ runtime: blender
+
+runtimes:
+ blender:
+ dockerfile: blender.dockerfile
+ python:
+ dockerfile: python.dockerfile
+
+preview:
+ - batch-services
+```
+
+## Run your renderer locally
+
+We can test our application locally using:
+
+```bash
+nitric run
+```
+
+We can then use any HTTP client capable of sending binary data with the request, like the Nitric [local dashboard](/get-started/foundations/projects/local-development#local-dashboard). Start by making a request using a static `.blend` scene:
+
+```bash
+curl --request PUT --data-binary "@cube.blend" http://localhost:4001/cube
+```
+
+We can then use the following request to render an animation. We have modified the render settings by setting
+
+- animate: true
+- device: GPU
+- engine: CYCLES
+- fps: 30
+- file_format: FFMPEG
+
+```bash
+curl --request PUT --data-binary "@animation.blend" "http://localhost:4001/animation?animate=true&device=GPU&engine=CYCLES&fps=30&file_format=FFMPEG"
+```
+
+## Deploy to the cloud
+
+At this point, you can deploy what you've built to any of the supported cloud providers. In this example we'll deploy to AWS. Start by setting up your credentials and configuration for the [nitric/aws provider](/providers/pulumi/aws).
+
+Next, we'll need to create a stack file (deployment target). A stack is a deployed instance of an application. You might want separate stacks for each environment, such as stacks for `dev`, `test`, and `prod`. For now, let's start by creating a file for the `dev` stack.
+
+The `stack new` command below will create a stack named `dev` that uses the `aws` provider.
+
+```
+nitric stack new dev aws
+```
+
+Edit the stack file `nitric.dev.yaml` and set your preferred AWS region, for example `us-east-1`.
+
+
+ You are responsible for staying within the limits of the free tier or any
+ costs associated with deployment.
+
+
+Let's try deploying the stack with the `up` command:
+
+```bash
+nitric up
+```
+
+When the deployment is complete, go to the relevant cloud console and you'll be able to see and interact with your Blender rendering application.
+
+To tear down your application from the cloud, use the `down` command:
+
+```bash
+nitric down
+```
+
+## Summary
+
+In this guide, we've created a remote Blender Renderer using Python and Nitric. We showed how to use batch jobs to run long-running workloads and connect these jobs to buckets to store rendered output. We also demonstrated how to expose buckets using simple CRUD routes on a cloud API. Finally, we were able to create dockerfiles with GPU support for optimal Blender rendering speeds.
+
+For more information and advanced usage, refer to the [Nitric documentation](/).
diff --git a/public/images/guides/blender-render/featured.png b/public/images/guides/blender-render/featured.png
new file mode 100644
index 000000000..37ac2212d
Binary files /dev/null and b/public/images/guides/blender-render/featured.png differ
diff --git a/src/components/code/annotations/collapse.tsx b/src/components/code/annotations/collapse.tsx
index dc92ebb75..c42573353 100644
--- a/src/components/code/annotations/collapse.tsx
+++ b/src/components/code/annotations/collapse.tsx
@@ -52,7 +52,7 @@ export const collapseTrigger: AnnotationHandler = {
const icon = props.data?.icon as React.ReactNode
return (