You do not need the Blender app, a desktop or a GPU to render with Blender. Since Blender is published as a Python package, pip install bpy gives you the whole thing, Cycles renderer included, as a module you import in a normal script. I used it to render a 13-second vertical video on a 4-core cloud container. Here is a minimal version you can run, and the numbers from the real job.

Three frames from a 13-second clip rendered with bpy on 4 CPU cores

Disclosure: I build data tools for AI agents as Fast Data on Apify; we render short data videos from code, which is how this came up. Nothing here needs our tools; the script is in our public data-recipes repo.

Install

python3.13 -m venv bvenv bvenv/bin/pip install bpy # ~430 MB wheel, Blender 5.3 at the time of writing 

The one trap: the wheel is built for exactly one Python version. bpy 5.3 is cp313, so it needs Python 3.13. On 3.12 pip simply finds no matching distribution, which looks like the package does not exist.

The script

It builds a scene from nothing (factory settings, empty), adds a glossy sphere, a floor, two area lights and a camera that orbits, and renders a PNG sequence:

import math, sys import bpy out_dir, n_frames = sys.argv[1].rstrip("/"), int(sys.argv[2]) bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene bpy.ops.mesh.primitive_plane_add(size=20) floor_mat = bpy.data.materials.new("floor"); floor_mat.use_nodes = True p = floor_mat.node_tree.nodes["Principled BSDF"] p.inputs["Base Color"].default_value = (0.02, 0.02, 0.025, 1) p.inputs["Roughness"].default_value = 0.25 bpy.context.object.data.materials.append(floor_mat) bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=(0, 0, 1), segments=96, ring_count=48) bpy.ops.object.shade_smooth() ball = bpy.context.object mat = bpy.data.materials.new("ceramic"); mat.use_nodes = True b = mat.node_tree.nodes["Principled BSDF"] b.inputs["Base Color"].default_value = (0.9, 0.9, 0.88, 1) b.inputs["Roughness"].default_value = 0.18 b.inputs["Coat Weight"].default_value = 1.0 ball.data.materials.append(mat) for name, loc, energy, color, size in [("key", (-4, -4, 6), 1500, (1, .95, .9), 4), ("rim", (4, 3, 3), 800, (.3, .9, 1), 1)]: light = bpy.data.lights.new(name, "AREA") light.energy, light.color, light.size = energy, color, size obj = bpy.data.objects.new(name, light); obj.location = loc obj.constraints.new("TRACK_TO").target = ball scene.collection.objects.link(obj) cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")) cam.data.lens = 50 cam.data.dof.use_dof, cam.data.dof.focus_object, cam.data.dof.aperture_fstop = True, ball, 2.8 cam.constraints.new("TRACK_TO").target = ball scene.collection.objects.link(cam); scene.camera = cam for f in range(n_frames): # key the camera on every frame a = -math.pi / 2 + 0.6 * f / max(n_frames - 1, 1) cam.location = (7 * math.cos(a), 7 * math.sin(a), 2.2) cam.keyframe_insert("location", frame=f + 1) scene.render.engine = "CYCLES" scene.cycles.device = "CPU" scene.cycles.samples = 16 scene.cycles.use_adaptive_sampling = True scene.cycles.use_denoising = True # OpenImageDenoise runs on CPU scene.cycles.max_bounces = 6 scene.render.resolution_x, scene.render.resolution_y = 1080, 1920 scene.render.resolution_percentage = 60 # 648x1152, upscale later scene.render.fps = 24 scene.render.use_motion_blur = True scene.view_settings.view_transform = "AgX" scene.frame_start, scene.frame_end = 1, n_frames scene.render.filepath = f"{out_dir}/" bpy.ops.render.render(animation=True) 

Run it and turn the frames into a video:

bvenv/bin/python blender_headless_render.py out 48 ffmpeg -framerate 24 -i out/%04d.png -c:v libx264 -pix_fmt yuv420p clip.mp4 

On the 4-core container this test scene took about 10 seconds per frame at 648x1152, plus roughly 10 seconds of start-up before the first frame. Two warnings appear (Material.use_nodes is scheduled for removal in Blender 6.0); they are harmless today.

What a real job cost

The clip in the image above is a lot heavier than the sphere: a procedurally modelled robot, three worlds (studio with light shafts, a lake at sunset with a physical sky and fog, a rainy neon street with 30,000 rain streaks), depth of field and motion blur. Measured on 4 shared CPU cores, with another render job running on the same machine:

Scene Seconds per frame at 648x1152, 12 samples
Studio (dark, two spotlights, fake light shafts) 11.2
Lake (sky, water, haze volume) 13.2
Neon street (rain, many emissive signs) 12.9
316 frames total 3,880 s, about 65 minutes

At 24 fps that is 13 seconds of video per hour of rendering. Quick look-dev stills at 640 px and 8 samples took 3 to 5 seconds each, which is what makes iterating from a script bearable.

The settings that mattered most

Setting Why
Low samples (12 to 16) plus OpenImageDenoise the denoiser does the work that 200+ samples would do; the biggest single speed-up
Render at 60 % and upscale with ffmpeg (lanczos + light sharpening) about 2.8x fewer pixels; on a phone screen the softness is hard to see
max_bounces 6, caustics off, indirect clamp cuts fireflies and time in glossy scenes
Fake volumetrics (additive emissive cones) instead of real volume scattering real volumes were about 3x slower in the studio shot
One PNG per frame, skip existing files on restart a crash after 40 minutes costs one frame, not the whole run
AgX view transform highlights roll off like a camera instead of clipping, for free

Things that did not work well: the hand that wipes across the lens smeared at 12 samples because motion blur plus denoising averages too little information; real volume scattering was too slow for this budget; and anything that should look worn (scratches, dirt) needs texture work that is slow to do purely procedurally.

When this is the right tool

  • Batch or templated renders: product turntables, data visualisations in 3D, the same scene with different text or numbers.
  • CI or serverless pipelines where a GPU is unavailable or expensive and you can wait an hour.
  • Not for interactive work or long films; on 4 cores, a minute of 1080p video at decent quality is a full day of rendering.

Try it

The script with comments: snippets/blender_headless_render.py. The repo also has Python snippets and n8n workflows for public European business data.

Question: has anyone benchmarked Cycles CPU against EEVEE in headless mode on a machine without a GPU? I stayed on Cycles because I was not sure EEVEE renders reliably without OpenGL; I would like to know if that assumption is outdated.