Skip to content

Building Custom OpenWrt Images: A Practical Guide

The OpenWrt build system (sometimes called buildroot, though it is distinct from the Buildroot project) is one of the most capable embedded Linux build systems available. It handles toolchain generation, cross-compilation, package management, and image assembly in a single coherent workflow.

It also has a reputation for being intimidating. This guide walks through the entire process from a clean machine to a working custom firmware image, including adding your own packages.

You need a Linux machine (or VM) with a reasonable amount of disk space and RAM. The build system does not run on macOS or Windows natively.

System requirements:

  • 10-15 GB of free disk space (more for multiple targets)
  • 4 GB RAM minimum, 8 GB recommended
  • A modern Linux distribution (Debian/Ubuntu, Fedora, Arch)

Install build dependencies (Debian/Ubuntu):

Terminal window
sudo apt update
sudo apt install -y build-essential clang flex bison g++ gawk \
gcc-multilib g++-multilib gettext git libncurses-dev libssl-dev \
python3-distutils python3-setuptools rsync swig unzip zlib1g-dev \
file wget

Install build dependencies (Fedora):

Terminal window
sudo dnf install -y @c-development @development-tools gcc gcc-c++ \
gawk gettext git ncurses-devel openssl-devel python3-devel \
rsync swig unzip wget zlib-devel

Clone the OpenWrt source and set up the feeds:

Terminal window
git clone https://git.openwrt.org/openwrt/openwrt.git
cd openwrt
# Check out a stable release branch
git checkout v23.05.5
# Update and install all default feeds
./scripts/feeds update -a
./scripts/feeds install -a

The feeds system is how OpenWrt manages packages outside the core repository. By default you get packages, luci (the web UI), routing, and telephony. Each feed is a separate Git repository that the build system pulls in.

menuconfig is the ncurses-based interface for selecting your target platform, packages, and build options.

Terminal window
make menuconfig

The top-level menus you care about most:

MenuPurpose
Target SystemSelect your SoC family (e.g., MediaTek Filogic, Qualcomm IPQ807x, x86)
SubtargetNarrow down the hardware variant
Target ProfileSelect your specific board/device
Base systemCore packages (busybox, uci, etc.)
NetworkNetworking packages (firewall, VPN, routing daemons)
LuCIWeb interface modules
Kernel modulesAdditional kernel module packages

Key bindings to remember:

  • Y — build into the image (built-in)
  • M — build as an installable package (not included in the image by default)
  • N — do not build
  • / — search for a package by name
  • ? — show help for the selected item

Tip: Always start from a known-good default config for your target. Many targets provide a defconfig or you can find community-maintained configs.

Terminal window
# For x86/64 generic target, start with:
make defconfig

Adding a custom feed is how you integrate your own packages into the build system. Edit feeds.conf.default (or create feeds.conf which takes precedence):

Terminal window
# feeds.conf
# Default feeds
src-git packages https://git.openwrt.org/feed/packages.git;openwrt-23.05
src-git luci https://git.openwrt.org/project/luci.git;openwrt-23.05
src-git routing https://git.openwrt.org/feed/routing.git;openwrt-23.05
# Your custom feed
src-git myfeed https://github.com/yourorg/openwrt-feed.git;main

Then update and install:

Terminal window
./scripts/feeds update myfeed
./scripts/feeds install -a -p myfeed

Your packages will now appear in menuconfig under the categories you define in their Makefiles.

This is where most people get stuck. An OpenWrt package Makefile looks different from a standard GNU Makefile. Here is a minimal but complete example for a C application:

# package/myapp/Makefile
include $(TOPDIR)/rules.mk
PKG_NAME:=myapp
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/yourorg/myapp.git
PKG_SOURCE_VERSION:=v$(PKG_VERSION)
PKG_MIRROR_HASH:=skip
include $(INCLUDE_DIR)/package.mk
define Package/myapp
SECTION:=utils
CATEGORY:=Utilities
TITLE:=My custom application
DEPENDS:=+libubox +libubus
endef
define Package/myapp/description
A custom application that does something useful on the router.
endef
define Build/Compile
$(MAKE) -C $(PKG_BUILD_DIR) \
CC="$(TARGET_CC)" \
CFLAGS="$(TARGET_CFLAGS)" \
LDFLAGS="$(TARGET_LDFLAGS)"
endef
define Package/myapp/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/myapp $(1)/usr/bin/
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/myapp.init $(1)/etc/init.d/myapp
endef
$(eval $(call BuildPackage,myapp))
  • PKG_SOURCE_* — tells the build system where to fetch your source code.
  • Package/myapp — defines the package metadata visible in menuconfig.
  • DEPENDS — declares runtime dependencies. The build system resolves these automatically.
  • Build/Compile — the cross-compilation step. Note the use of $(TARGET_CC) and related variables. Never hardcode gcc here.
  • Package/myapp/install — defines what files go into the package (and thus onto the device). $(1) is the package staging root.

For packages with their own build system (autotools, CMake), OpenWrt provides helpers:

# For autotools projects
include $(INCLUDE_DIR)/autotools.mk
# For CMake projects
include $(INCLUDE_DIR)/cmake.mk

Cross-compiling for embedded targets has its own set of pitfalls. A few things we have learned:

Use the toolchain variables. The build system exports TARGET_CC, TARGET_CXX, TARGET_CFLAGS, TARGET_LDFLAGS, and many more. Always use them.

Test with staging dir libraries. If your package links against a library that is also an OpenWrt package, the headers and .so files live in $(STAGING_DIR)/usr/include and $(STAGING_DIR)/usr/lib. The build system sets these paths automatically for most build systems, but custom Makefiles may need explicit -I and -L flags.

Debug with QEMU. For architectures like ARM and MIPS, you can use QEMU user-mode emulation to test your binaries without deploying to real hardware:

Terminal window
# Install QEMU user-mode (Debian/Ubuntu)
sudo apt install qemu-user-static
# Run an ARM binary on your x86 host
qemu-arm-static -L $(STAGING_DIR) $(PKG_BUILD_DIR)/myapp

Watch your binary size. Embedded targets often have limited flash storage. Use $(TARGET_CFLAGS) which typically includes -Os for size optimization. Strip your binaries (the build system does this by default).

Committing a full .config file to version control is noisy — it contains thousands of lines, most of which are defaults. Use diffconfig instead:

Terminal window
# Generate a minimal config diff (only non-default options)
./scripts/diffconfig.sh > configs/mydevice.diffconfig
# Later, restore the full config from the diff
cp configs/mydevice.diffconfig .config
make defconfig

This diffconfig output is typically 20-50 lines instead of 5000+, making it practical to review in code review.

Pin your feed revisions for full reproducibility:

Terminal window
# Record exact feed revisions
cat feeds.conf.default
# src-git packages https://git.openwrt.org/feed/packages.git^abc123def

Firmware builds are slow (30-60 minutes for a clean build, depending on target and machine), but they are automatable. Here is a sketch of a CI pipeline:

# .github/workflows/firmware-build.yml (simplified)
name: Build Firmware
on:
push:
branches: [main]
paths:
- 'configs/**'
- 'packages/**'
- 'feeds.conf'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt update
sudo apt install -y build-essential clang flex bison g++ gawk \
gcc-multilib gettext git libncurses-dev libssl-dev \
python3-distutils rsync unzip zlib1g-dev file wget
- name: Clone OpenWrt
run: |
git clone --depth 1 --branch v23.05.5 \
https://git.openwrt.org/openwrt/openwrt.git
- name: Configure and build
run: |
cd openwrt
cp ../feeds.conf .
./scripts/feeds update -a
./scripts/feeds install -a
cp ../configs/mydevice.diffconfig .config
make defconfig
make -j$(nproc) V=s
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-images
path: openwrt/bin/targets/

Caching tip: The OpenWrt build system downloads source tarballs into dl/. Cache this directory between CI runs to significantly reduce build times.

- name: Cache downloads
uses: actions/cache@v4
with:
path: openwrt/dl
key: openwrt-dl-${{ hashFiles('feeds.conf') }}

The OpenWrt build system rewards the time you invest in understanding it. Once you have a working setup, iterating is fast — incremental builds after a package change take seconds, not minutes.

Key takeaways:

  • Start from a stable release tag, not main.
  • Use diffconfig for version control, not the full .config.
  • Put custom packages in a separate feed to keep your work decoupled from upstream.
  • Pin feed revisions for reproducible builds.
  • Automate your builds in CI from day one.

If you hit a wall, the OpenWrt forum and the #openwrt-devel IRC channel on OFTC are excellent resources. The community is active and generally welcoming to newcomers working on real problems.

v1.7.9