To create an device image and populate it from an existing SD card or image:
Calculate the total size of the first partition and the content of the second one, leaving at least an extra 10%. If that is 4 GiB, here's how to create the base image with dd:
dd if=/dev/zero of=new.img bs=4M count=1024
Use fdisk new.img; this will automatically create an MBR table. You can then create the two partitions:
- Copy the offset and size of the first one from your source, probably the start sector is 8192 and the end for a 256 MiB partition is 532480. Then use
t to set the partition type to W95 FAT32 (option c).
- Use
p to view the current partition table, then create the second one big enough to fill the rest of the image -- beware because the offset of the first one is 8192, fdisk will offer you a default starting offset for the second one before that, you want to use the End of the last one + 1. The default then offered for the end should be fine, and you can leave the default type flag.
- Get the PARTUUID used in the source image from
cmdline.txt and use x ("expert options") then i to set the one in the new image the same, sans -02. This is simpler than changing it in cmdline.txt and /etc/fstab.
- Use
r to get out of expert options then w to write the table and exit fdisk. You can then double check the img with fdisk -l new.img.
Now mount the two partitions:
Associate a loop device with the image: sudo losetup -P /dev/loop1 new.img
Create the filesystems:
sudo mkfs -t vfat /dev/loop1p1
sudo mkfs -t ext4 /dev/loop1p2
Create arbitrary mount points, eg., /mnt/img-1 and /mnt/img-2, and mount the partitions there:
sudo mount /dev/loop1p1 /mnt/img-1
sudo mount /dev/loop1p2 /mnt/img-2
You can now copy the data over. This is much easier if it is not from a running system; if you have no other choice see the caveats here first.
sudo rsync -rtv /source-first-partition /mnt/img-1
sudo rsync -aHAXv /source-second-partition /mnt/img-2
Unmount the partitions (this may take a second, and is important), and you're done. This can then be written directly to an SD card using dd or whatever tool you normally use.
Enabling auto-resizing of the image is a separate issue; raspi-config uses fdisk to grow the partition then resize2fs to grow the filesystem inside (which is pretty much the only way to do this with commonly available tools). It can be invoked non-interactively this way:
raspi-config --expand-rootfs
Which could easily be put into a boot service that then disables or deletes itself and reboots (as long as it happens at an appropriate point, or is set as the init= process in cmdline.txt, you'll have to wrap that in another shell script since this command cannot include whitespace -- use the full /usr/bin/raspi-config path as well.
As per Milliway's answer, /usr/lib/raspi-config/init_resize.sh is written to be used this way (via init=) -- but contra that answer it isn't used by default anymore (I am very sure it once was, and suspect the Pi imager replaced some of the functionality). Note that it does do a number of other things besides resizing and you should have a look at that before applying it (I would recommend the raspi-config method above instead).