It is possible to create an empty image for a Raspberry Pi. Then you can copy an operating system with a boot and root partition to it and boot. To fill the empty image you can use any usable file copy method, e.g. from a tar archive, or rsync, or just cp. This way you are completely free to shrink or expand storage, or use other partition sizes than given by the original images. You can do all of it on a linux laptop, no need to use a Raspberry Pi. If you have made the image you can flash it to an SD Card and boot it in a RasPi. I have tested it by copying Raspbian Stretch Lite and Raspbian Buster Lite to a modified partition scheme and boot it up.
Create an empty image file
We just create a file with Null bytes of the size we want, partition it and format the partitions in the file. I will use an image with 2.2 GB (2200 MB).
This gives the empty file:
laptop ~$ sudo -Es
laptop ~# dd of=raspbian.img seek=2200M bs=1 count=0
Create a default msdos partition table on the virgin image and partition it for a fat32 boot partition and an ext4 root partition:
laptop ~# parted raspbian.img mktable msdos
laptop ~# parted raspbian.img mkpart primary fat32 8192s 260MB
laptop ~# parted raspbian.img mkpart primary ext4 260MB 100%
Now we have made a master boot record (MBR), the very first sector with 512 bytes. It also contains a very small boot sequence at the beginning and the partition table at the end. You can check it with:
laptop ~# hd -v -n512 raspbian.img
But the boot sequence is not used by a Raspberry Pi so we will delete it with:
laptop ~# dd if=/dev/zero of=raspbian.img bs=1 count=440 conv=notrunc
Have an attention of the
count=440. A greater number will also delete the partition table. Deleting the boot sequence is not really required but I do it to be on the save side because the MBR from the original Raspbian image is also nullified on this sequence.
Now we have to format the partitions with a file system but there is a problem. The formating programs need a block device file from directory
/dev. Fortunately we can use loop devices to address the partitions as block devices but we have exactly to know where they are lying in the image file. We show it with parted and use the Start an Size for the loop devices. You will find the right numbers from the example:
laptop ~# parted raspbian.img unit s print free
Disk /home/ingo/devel/raspi/raspbian.img: 4505600s
Sector size (logical/physical): 512B/512B
Partition Table: msdos
Number Start End Size Type File system Flags
32s 8191s 8160s Free Space
1 8192s 507903s 499712s primary lba
2 507904s 4505599s 3997696s primary
# define loop devices
laptop ~# losetup /dev/loop1 raspbian.img --offset $((8192*512)) --sizelimit $((499712*512))
laptop ~# losetup /dev/loop2 raspbian.img --offset $((507904*512)) --sizelimit $((3997696*512))
# format partitions
laptop ~# mkfs.vfat -F 32 -n BOOT /dev/loop1
laptop ~# mkfs.ext4 -L rootfs /dev/loop2
# clean up
laptop ~# losetup -d /dev/loop1
laptop ~# losetup -d /dev/loop2
Now we have a formated but empty image to which we can copy an operating system.
Copy an operating system to the empty image file
For example I will use a SD Card attached to
/dev/sdb. Its content will be copied to the image, provided it all fits to the new image file. Here I use a SD Card with Raspbian Buster Lite. I will mount the SD Card and the image partitions and copy the data:
# mount the SD Card
laptop ~# mkdir /mnt/sdcp2
laptop ~# mount /dev/sdb2 /mnt/sdcp2/
laptop ~# mount /dev/sdb1 /mnt/sdcp2/boot
To mount the image partitions you will find the offsets with
parted as shown in the example above.
# mount the image partitions
laptop ~# mkdir /mnt/imgp2
laptop ~# mount -o loop,offset=$((507904*512)),sizelimit=$((3997696*512)) raspbian.img /mnt/imgp2/
laptop ~# mkdir /mnt/imgp2/boot
laptop ~# mount -o loop,offset=$((8192*512)),sizelimit=$((499712*512)) raspbian.img /mnt/imgp2/boot
# copy the SD Card
laptop ~# cp -a /mnt/sdcp2/* /mnt/imgp2/
If you coppied Raspbian then change device name for root partition in
boot/cmdline.txt and in
etc/fstab to /dev/mmcblk0p1 and /dev/mmcblk0p2 because the used PARTUUID there isn't valid anymore. Check on other operating systems if they address the partitions the right way.
laptop ~# sed -i 's/root=PARTUUID=[a-z0-9]*-02/root=\/dev\/mmcblk0p2/' /mnt/imgp2/boot/cmdline.txt
laptop ~# sed -i 's/^PARTUUID=[a-z0-9]*-01/\/dev\/mmcblk0p1/' /mnt/imgp2/etc/fstab
laptop ~# sed -i 's/^PARTUUID=[a-z0-9]*-02/\/dev\/mmcblk0p2/' /mnt/imgp2/etc/fstab
# clean up
laptop ~# umount /mnt/imgp2/boot
laptop ~# umount /mnt/imgp2
laptop ~# rmdir /mnt/imgp2
laptop ~# umount /mnt/sdcp2/boot
laptop ~# umount /mnt/sdcp2
laptop ~# rmdir /mnt/sdcp2
That's it. Flash raspbian.img to an SD Card and boot it in a RasPi.
Have in mind that we are working in units (sectors) of 512 bytes.
We have to address the beginning of the partitions in the image. This is done with an offset to the mount command. For details to do this look at (1).
By default Raspbian uses
PARTUUID to address storage and partitions on it. But this is a problem because with a new image we also have new
PARTUUID. Then you have the problem that the installation does not boot. So we change the partition names to the more generic names
 How can I mount a Raspberry Pi Linux distro image?
 Is it possible to use partition UUID for root-parameter in cmdline.txt?