As some of you may have already noticed, the latest Raspberry Pi OS release based on Debian Trixie now includes cloud-init. This marks the beginning of a transition away from our legacy first-boot customisation system based on the firstrun.sh script.
Cloud-init is a cross-platform, distribution-agnostic tool used to automatically configure systems on first boot. (Definition adapted from the official cloud-init documentation.) With it, you can provision your Raspberry Pi images with users, network settings, SSH keys, storage configurations, and much more — all without manually logging in after flashing the image.
If you’ve downloaded the latest image (released on 2 October 2025), you’ll find that three new files have appeared on the boot partition. This is the FAT32 partition that your computer automatically mounts when you insert a freshly flashed Raspberry Pi OS microSD card. It already contains familiar files like config.txt, but now you’ll also see:
meta-datanetwork-configuser-dataFor most users, meta-data can be left untouched — it simply enables cloud-init to process the other configuration files correctly. Advanced users may use it for more complex provisioning workflows, but it’s safe to ignore in typical setups.
Cloud-init uses YAML for its configuration files. If you’re new to YAML, it’s worth taking a quick look at the official documentation, as indentation and formatting matter. For now, we’ll focus on the two most relevant files: user-data and network-config.
user-data)The user-data file is the central place for your configuration. With the exception of networking, almost everything cloud-init sets up on first boot is controlled from here.
You can use it to create a default user, define your locale, install additional packages, configure SSH access, and much more — all of which is covered in the official cloud-init documentation.
Unlike many other distributions, Raspberry Pi OS includes a few Raspberry Pi–specific extensions for cloud-init configuration. These allow you to enable hardware interfaces such as I2C, SPI, serial, and 1-Wire, and even activate USB gadget mode (rpi-usb-gadget) automatically.
Here’s an example configuration that sets up a user and demonstrates all the currently supported Raspberry Pi–specific options:
#cloud-config
# Set the hostname for this device. This will also update /etc/hosts if manage_etc_hosts is enabled.
hostname: mypi2025
manage_etc_hosts: true
# Set the system timezone
timezone: Europe/London
# Create a default user account and apply permissions
users:
- name: pi
groups: users,adm,dialout,audio,netdev,video,plugdev,cdrom,games,input,gpio,spi,i2c,render,sudo
shell: /bin/bash
lock_passwd: false # Set to true to disable password login entirely
plain_text_password: mysecretpassword123 # Alternatively, use 'passwd:' with a hashed password for better security
ssh_authorized_keys:
- ssh-ed25519 mykeystuff # Replace with your actual SSH public key
sudo: ALL=(ALL) NOPASSWD:ALL # Allow passwordless sudo for this user
# Raspberry Pi–specific options (provided by the cc_raspberry_pi module)
rpi:
spi: true # Enable SPI interface
i2c: true # Enable I2C interface
serial: true # Enable serial console and UART interface
onewire: true # Enable 1-Wire interface
enable_usb_gadget: true # Enable USB gadget mode
# Additional Raspberry Pi OS option (not available on generic cloud-init images)
enable_ssh: true # Enables the SSH server on first boot
# Optional: Disable SSH password authentication if using SSH keys only (recommended for security)
# ssh_pwauth: false
For more details, you can refer to the cc_raspberry_pi module in the official cloud-init documentation.
Note:
The#cloud-configheader at the top of the file is mandatory — cloud-init will not process the file correctly without it.
network-config)The network-config file defines how your Raspberry Pi should set up its network interfaces on first boot. As the name suggests, this is where you configure Wi-Fi or Ethernet settings before ever powering on the device.
Here’s a simple example that connects your Raspberry Pi to a Wi-Fi network:
network:
version: 2
wifis:
# Make sure the target is NetworkManager which is the default on Raspberry Pi OS
renderer: NetworkManager
# The connection name
wlan0:
dhcp4: true
# !VERY IMPORTANT! Change this to the ISO/IEC 3166 country code for the country you want to use this microSD card in.
regulatory-domain: "GB"
access-points:
"My Net-Work":
password: "mysupersecretpassword"
# Don’t wait at boot for this connection to connect successfully
optional: true
When you power on your Raspberry Pi with this microSD card inserted, cloud-init will process this configuration and attempt to connect to the specified network automatically — allowing you to SSH in or continue working without needing to attach a screen or a keyboard.
You can configure far more than just basic Wi-Fi credentials: multiple networks, priority fallback, static IP assignments, VLANs, and more are supported. For a full reference, see the official cloud-init networking documentation.
With the introduction of cloud-init, Raspberry Pi OS also includes Netplan, a unified abstraction layer for network configuration used by several modern Linux distributions.
Netplan is now the primary source of truth for networking on Raspberry Pi OS. It uses its own YAML-based configuration format and can render network settings for both systemd-networkd and NetworkManager, depending on which renderer you choose. The major advantage of this approach is portability — a Netplan configuration can be reused across any Linux distribution that supports it, regardless of whether it uses NetworkManager or networkd underneath.
To use Netplan directly, place your configuration files in /etc/netplan/ — this is also where cloud-init stores your generated network configuration from network-config without modification. From there, you can generate the active configuration using:
sudo netplan generate
This writes the appropriate configuration files for the selected backend (NetworkManager on Raspberry Pi OS). To activate the configuration, run:
sudo netplan apply
You can still use nmcli as usual to inspect or manage connections. Since many existing tools and scripts rely on nmcli or the NetworkManager D-Bus API, there needs to be a communication layer between Netplan and NetworkManager. Canonical provides three patches that enable this two-way interoperability, allowing NetworkManager to signal configuration changes back to Netplan.
For Raspberry Pi OS, we’ve gone a step further and introduced additional patches to improve this workflow:
netplan- prefix. If you want a new connection to be persisted by Netplan, give it that prefix when creating it, and it will be stored in Netplan’s configuration..yaml and .yml files under /etc/netplan/ are cleared to avoid conflicting definitions across multiple layers./etc/NetworkManager/system-connections/./run/NetworkManager/system-connections/ and will also use the netplan- prefix.This approach ensures consistency between both systems and prevents configuration drift when editing or reloading profiles via NetworkManager tools.
With cloud-init and Netplan now integrated into Raspberry Pi OS, first-boot provisioning becomes far more powerful, repeatable, and portable across different setups. Whether you’re configuring a single device or preparing dozens of Raspberry Pis for a classroom, a lab, or an IoT deployment, these new tools make it easy to define everything up front — users, networking, interfaces, and more — before the system even powers on.
With the release of Raspberry Pi Imager 2.0, cloud-init configuration for Raspberry Pi OS is now generated by default. This makes it easy to further customise your setup after writing the image — simply edit the generated user-data or network-config files on the boot partition. Imager 2.0 also understands the Raspberry Pi–specific rpi: options, so features like SPI or I2C can be enabled directly in the customisation UI.
The legacy method still works, but cloud-init and Netplan open the door to a much more flexible and modern workflow. We’ll continue expanding support for Raspberry Pi–specific cloud-init modules and streamlined provisioning features in future releases.
If you create interesting user-data or network-config templates, or have feedback about the new system, we’d love to hear from you in the forums.
Header image resources: Background designed by Freepik. Icons by ziadarts, Dimas Anom, and Gregor Cresnar via Flaticon.
The post Cloud-init on Raspberry Pi OS appeared first on Raspberry Pi.
What is a Background Task?
A background task (or background service) is work that runs behind the scenes in an application without blocking the main user flow and often without direct user interaction.
Think of it as a worker or helper that performs tasks independently while the main app continues doing other things.
Problem Statement -
What do you do when your downstream API is flaky or sometimes down for hours or even days , yet your UI and main API must stay responsive?
Solution -
This is a very common architecture problem in enterprise systems, and .NET gives us excellent tools to solve it cleanly: BackgroundService and exponential backoff retry logic.
In this article, I’ll walk you through:
The Use Case
You have two APIs:
If API 1 directly calls API 2:
* Users experience lag
* API 1 becomes slow or unusable
* You overload API 2 with retries
* Calls fail when API 2 is offline
* You lose data
What do we do then? Here goes the solution
The Architecture
Instead of calling API 2 synchronously, API 1 simply stores the intended call, and returns immediately.
A BackgroundService will later:
This creates a resilient, smooth, non-blocking system.
Why Exponential Backoff?
When a downstream API is completely offline, retrying every 1–5 seconds is disastrous:
Exponential backoff solves this.
Examples retry delays:
Retry 1 → 2 sec
This gives the system room to breathe.
Complete Working Example (Using In-Memory Store)
1. The Model
2. The In-Memory Store
public interface IPendingJobStore { Task AddJobAsync(string payload); Task<List<PendingJob>> GetExecutableJobsAsync(); Task MarkJobAsCompletedAsync(Guid jobId); Task UpdateJobAsync(PendingJob job); } public class InMemoryPendingJobStore : IPendingJobStore { private readonly List<PendingJob> _jobs = new(); private readonly object _lock = new(); public Task AddJobAsync(string payload) { lock (_lock) { _jobs.Add(new PendingJob { Payload = payload, RetryCount = 0, NextRetryTime = DateTime.UtcNow }); } return Task.CompletedTask; } public Task<List<PendingJob>> GetExecutableJobsAsync() { lock (_lock) { return Task.FromResult(_jobs.Where(j => !j.Completed && j.NextRetryTime <= DateTime.UtcNow).ToList()); } } public Task MarkJobAsCompletedAsync(Guid jobId) { lock (_lock) { var job = _jobs.FirstOrDefault(j => j.Id == jobId); if (job != null) job.Completed = true; } return Task.CompletedTask; } public Task UpdateJobAsync(PendingJob job) => Task.CompletedTask; }3. The BackgroundService with Exponential Backoff
using System.Text; public class Api2RetryService : BackgroundService { private readonly IHttpClientFactory _clientFactory; private readonly IPendingJobStore _store; private readonly ILogger<Api2RetryService> _logger; public Api2RetryService(IHttpClientFactory clientFactory, IPendingJobStore store, ILogger<Api2RetryService> logger) { _clientFactory = clientFactory; _store = store; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Background retry service started."); while (!stoppingToken.IsCancellationRequested) { var jobs = await _store.GetExecutableJobsAsync(); foreach (var job in jobs) { var client = _clientFactory.CreateClient("api2"); try { var response = await client.PostAsync("/simulate", new StringContent(job.Payload, Encoding.UTF8, "application/json"), stoppingToken); if (response.IsSuccessStatusCode) { _logger.LogInformation("Job {JobId} processed successfully.", job.Id); await _store.MarkJobAsCompletedAsync(job.Id); } else { await HandleFailure(job); } } catch (Exception ex) { _logger.LogError(ex, "Error calling API 2."); await HandleFailure(job); } } await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } private async Task HandleFailure(PendingJob job) { job.RetryCount++; var delay = CalculateBackoff(job.RetryCount); job.NextRetryTime = DateTime.UtcNow.Add(delay); await _store.UpdateJobAsync(job); _logger.LogWarning("Retrying job {JobId} in {Delay}. RetryCount={RetryCount}", job.Id, delay, job.RetryCount); } private TimeSpan CalculateBackoff(int retryCount) { var seconds = Math.Pow(2, retryCount); var maxSeconds = TimeSpan.FromMinutes(5).TotalSeconds; return TimeSpan.FromSeconds(Math.Min(seconds, maxSeconds)); } }4. The API 1 — Public Endpoint
using System.Runtime.InteropServices; using System.Text.Json; [ApiController] [Route("api1")] public class Api1Controller : ControllerBase { private readonly IPendingJobStore _store; private readonly ILogger<Api1Controller> _logger; public Api1Controller(IPendingJobStore store, ILogger<Api1Controller> logger) { _store = store; _logger = logger; } [HttpPost("process")] public async Task<IActionResult> Process([FromBody] object data) { var payload = JsonSerializer.Serialize(data); await _store.AddJobAsync(payload); _logger.LogInformation("Stored job for background processing."); return Ok("Request received. Will process when API 2 recovers."); } }5. The API 2 (Simulating Downtime)
using System.Runtime.InteropServices; [ApiController][Route("api2")] public class Api2Controller: ControllerBase { private static bool shouldFail = true; [HttpPost("simulate")] public IActionResult Simulate([FromBody] object payload) { if (shouldFail) return StatusCode(503, "API 2 is down"); return Ok("API 2 processed payload"); } [HttpPost("toggle")] public IActionResult Toggle() { shouldFail = !shouldFail; return Ok($"API 2 failure mode = {shouldFail}"); } }6. The Program.cs
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddSingleton<IPendingJobStore, InMemoryPendingJobStore>(); builder.Services.AddHttpClient("api2", c => { c.BaseAddress = new Uri("http://localhost:5000/api2"); }); builder.Services.AddHostedService<Api2RetryService>(); var app = builder.Build(); app.MapControllers(); app.Run();Testing the Whole Flow
#1 API 2 starts in failure mode
All attempts will fail and exponential backoff kicks in.
#2 Send a request to API 1
POST /api1/process
{
"name": "hello"
}
Job is stored.
#3 Watch logs
You’ll see:
Retrying job in 2 seconds...
Retrying job in 4 seconds...
Retrying job in 8 seconds...
...
#4 Bring API 2 back online:
POST /api2/toggle
Next retry will succeed:
Job {id} processed successfully.
Conclusion
This pattern is extremely powerful for:
References
In this bonus conversation with Rob Drummond from back in June, he and I get into the fascinating concept of "languaging" — the idea that speaking is an active process we use to constantly shape and project our identities. Rob explains how our "speaking identities" are incredibly fluid, changing based on context, audience, and even the language we're using.
Rob Drummond - https://bsky.app/profile/robdrummond.bsky.social
Rob's book, "You're All Talk"
🔗 Share your familect recording via Speakpipe, by calling 833-214-4475 (or via WhatsApp chat.)
🔗 Watch my LinkedIn Learning writing courses.
🔗 Subscribe to the newsletter.
🔗 Take our advertising survey.
🔗 Get the edited transcript.
🔗 Get Grammar Girl books.
🔗 Join Grammarpalooza. Get ad-free and bonus episodes at Apple Podcasts or Subtext. Learn more about the difference.
| HOST: Mignon Fogarty
| Grammar Girl is part of the Quick and Dirty Tips podcast network.
| Theme music by Catherine Rannus.
| Grammar Girl Social Media: YouTube. TikTok. Facebook.Threads. Instagram. LinkedIn. Mastodon. Bluesky.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.