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.
Prerequisites
Section titled “Prerequisites”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):
sudo apt updatesudo 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 wgetInstall build dependencies (Fedora):
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-develCloning and Initial Setup
Section titled “Cloning and Initial Setup”Clone the OpenWrt source and set up the feeds:
git clone https://git.openwrt.org/openwrt/openwrt.gitcd openwrt
# Check out a stable release branchgit checkout v23.05.5
# Update and install all default feeds./scripts/feeds update -a./scripts/feeds install -aThe 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.
Navigating menuconfig
Section titled “Navigating menuconfig”menuconfig is the ncurses-based interface for selecting your target platform, packages, and build options.
make menuconfigThe top-level menus you care about most:
| Menu | Purpose |
|---|---|
| Target System | Select your SoC family (e.g., MediaTek Filogic, Qualcomm IPQ807x, x86) |
| Subtarget | Narrow down the hardware variant |
| Target Profile | Select your specific board/device |
| Base system | Core packages (busybox, uci, etc.) |
| Network | Networking packages (firewall, VPN, routing daemons) |
| LuCI | Web interface modules |
| Kernel modules | Additional 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.
# For x86/64 generic target, start with:make defconfigCustomizing Feeds
Section titled “Customizing Feeds”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):
# feeds.conf# Default feedssrc-git packages https://git.openwrt.org/feed/packages.git;openwrt-23.05src-git luci https://git.openwrt.org/project/luci.git;openwrt-23.05src-git routing https://git.openwrt.org/feed/routing.git;openwrt-23.05
# Your custom feedsrc-git myfeed https://github.com/yourorg/openwrt-feed.git;mainThen update and install:
./scripts/feeds update myfeed./scripts/feeds install -a -p myfeedYour packages will now appear in menuconfig under the categories you define in their Makefiles.
Creating a Custom Package
Section titled “Creating a Custom Package”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:=myappPKG_VERSION:=1.0.0PKG_RELEASE:=1
PKG_SOURCE_PROTO:=gitPKG_SOURCE_URL:=https://github.com/yourorg/myapp.gitPKG_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 +libubusendef
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/myappendef
$(eval $(call BuildPackage,myapp))Makefile anatomy
Section titled “Makefile anatomy”- 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 hardcodegcchere. - 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 projectsinclude $(INCLUDE_DIR)/autotools.mk
# For CMake projectsinclude $(INCLUDE_DIR)/cmake.mkCross-Compilation Tips
Section titled “Cross-Compilation Tips”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:
# Install QEMU user-mode (Debian/Ubuntu)sudo apt install qemu-user-static
# Run an ARM binary on your x86 hostqemu-arm-static -L $(STAGING_DIR) $(PKG_BUILD_DIR)/myappWatch 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).
Reproducible Builds with Config Diffing
Section titled “Reproducible Builds with Config Diffing”Committing a full .config file to version control is noisy — it contains thousands of lines, most of which are defaults. Use diffconfig instead:
# Generate a minimal config diff (only non-default options)./scripts/diffconfig.sh > configs/mydevice.diffconfig
# Later, restore the full config from the diffcp configs/mydevice.diffconfig .configmake defconfigThis 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:
# Record exact feed revisionscat feeds.conf.default# src-git packages https://git.openwrt.org/feed/packages.git^abc123defCI/CD Integration
Section titled “CI/CD Integration”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') }}Wrapping Up
Section titled “Wrapping Up”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
diffconfigfor 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.