Fedora Atomic Sway running on mac M2 using qemu
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Me Here 97abf36967 README: create is verified end to end on hardware
A full recreate now runs from scratch to a provisioned VM on the M2 against
Fedora 44 - derived image, disk build, first boot, sudo bootstrap, hostshare
automount, and the composefs root grow. The limitations section said the
provisioning path had only been checked by inspection, which is no longer
true. Replaces that with what was actually exercised and on what, and flags
FEDORA_RELEASE as the knob most likely to invalidate it: the per-transport
sshd layout and the composefs root are both release-specific.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:44:51 -05:00
.gitignore Narrow the gitignore to .claude/settings.local.json 2026-08-18 10:40:06 -05:00
CLAUDE.md Enable TCP sshd via a derived container image 2026-08-18 11:16:21 -05:00
README.md README: create is verified end to end on hardware 2026-08-18 11:44:51 -05:00
swaybox.sh Enable TCP sshd via a derived container image 2026-08-18 11:16:21 -05:00

swayonmac

Run Fedora Sway Atomic as a desktop VM on an Apple Silicon Mac, under QEMU.

swaybox.sh is the whole project: one Bash script that builds the disk image, boots the VM in a window, and manages its lifecycle. There is nothing to install, compile, or configure beyond the script itself.

This exists because you want the Sway UI specifically — a tiling Wayland compositor, running as a real desktop in a window on macOS — not because a VM is a convenient way to get a Linux shell. The intended workload inside the guest is a terminal plus Chromium.


Requirements

  • macOS on Apple Silicon (M1/M2/…). x86_64 Macs are handled but incidental.
  • Homebrew packages:
    brew install qemu podman socat
    
    QEMU is needed for everything. Podman is needed only for create, which builds the disk image. socat is used as a fallback path when shutting down.
  • An SSH public key at ~/.ssh/id_ed25519.pub, or set SSH_KEY to point at one. Generate one with ssh-keygen -t ed25519 if you don't have it.
  • Free disk space. The disk image is thin-provisioned and grows as you use it, but its ceiling defaults to 64 GB.

About memory

The reference setup is an M2 with 8 GB of unified memory, and that is the binding constraint on this whole project — not CPU, not disk. macOS needs about 3 GB, QEMU adds roughly 0.4 GB of overhead on top of whatever the guest gets, and the rest is yours.

The default RAM_MB=3584 (3.5 GB) is chosen to leave the Mac usable while still fitting a browser inside the guest. If you have 16 GB or more, raising it is the single most effective change you can make.


Quick start

git clone <this repo> && cd swayonmac

./swaybox.sh create     # build the disk image — several minutes, several GB
./swaybox.sh run        # opens a window; Sway is running inside it

create also boots the VM once, headless, to finish provisioning, then shuts it down. When it's finished, run is what you use day to day.

run detaches: the VM window opens and your prompt returns immediately. That also means Ctrl-C in the terminal no longer stops the VM — shut it down from inside Sway, or with ./swaybox.sh stop. Closing the terminal leaves the VM running.

To get a shell without the graphical window:

./swaybox.sh run-bg     # boot headless
./swaybox.sh ssh        # ssh in
./swaybox.sh ssh 'uptime'   # or run a single command

Commands

Command What it does
create Build disk.qcow2 with bootc-image-builder, then provision it
setup Re-run guest provisioning on a VM that already exists and is running
run Boot with a Cocoa window — Sway as your live desktop. Detaches, so your terminal comes straight back
run-bg Boot headless with VNC on 127.0.0.1:5900
stop Graceful shutdown (SSH → QMP → kill -9)
restart stop, then run
destroy Delete the disk image (keeps the pulled container image cache)
recreate destroy + create
ssh [cmd] SSH into the running VM, optionally running one command
status Whether the VM is running, and how to connect
password Print the console login password

create is the expensive one: it pulls multi-GB container images, switches your Podman machine to rootful mode (stopping it first if it's running), and takes many minutes. setup exists so you can apply provisioning changes to an existing VM without paying that cost again.


Configuration

Everything is set by environment variable — you should not need to edit the script. Set them per-invocation, or export them in your shell profile.

RAM_MB=6144 ./swaybox.sh run
Variable Default Notes
VM_DIR ~/vms/fedora-sway Where the disk and runtime state live
RAM_MB 3584 Guest RAM. See the memory note above
CPUS 4 vCPUs. These cost no host RAM
DISK_SIZE 64G Ceiling, not an allocation. Applied at create only
VM_USER user Account created inside the guest
VM_HOSTNAME swaybox Guest hostname
SSH_PORT 2222 Host port forwarded to the guest's port 22
VNC_DISPLAY :0 VNC display for run-bg (:0 = port 5900)
SHARE_DIR ~/vm-share Host directory shared into the guest
SSH_KEY ~/.ssh/id_ed25519.pub Public key injected at build time
BOOTC_IMAGE quay.io/fedora-ostree-desktops/sway-atomic:44 Source image
FEDORA_RELEASE 44 Used to build the default BOOTC_IMAGE
BALLOON 1 virtio-balloon, so the host can reclaim idle guest RAM
VM_PASSWORD (generated) Set the console password at create time — see Logging in
GL 0 Set to 1 for GPU acceleration — see Graphics below

⚠️ Paths must not contain spaces. QEMU arguments are assembled as strings and eval'd, so a SHARE_DIR or VM_DIR containing a space will break the command line.


Logging in

You get two credentials, and you need both:

  • Your SSH key gets you in over the network (./swaybox.sh ssh).
  • A console password is what you type at the graphical Sway login screen, because a login screen cannot authenticate an SSH key.

By default the password is generated at first create, stored in $VM_DIR/.password with mode 0600, and printed by:

./swaybox.sh password

Choosing the password yourself

You do not have to accept a generated one. create picks a password in this order, first match wins:

  1. VM_PASSWORD in the environment — set it for the create invocation:
    VM_PASSWORD='correct horse battery staple' ./swaybox.sh create
    
  2. An existing $VM_DIR/.password file — write it yourself before the first create and that is the password you get:
    mkdir -p ~/vms/fedora-sway
    printf 'my-chosen-password\n' > ~/vms/fedora-sway/.password
    chmod 600 ~/vms/fedora-sway/.password
    ./swaybox.sh create
    
    The file should hold the password on a single line and nothing else.
  3. Generated — a 20-character random string, only when neither of the above is present.

Whichever path is taken, the result is written back to $VM_DIR/.password, so ./swaybox.sh password always prints the real one.

⚠️ The password is baked into the disk image at create time. Editing .password afterwards changes the file, not the guest — you'd be left with a file that no longer matches the login. To change the password on a VM that already exists, change it inside the guest with passwd, and update .password yourself to match if you want the two to agree.

destroy deliberately leaves .password alone, and create reuses an existing one, so recreate won't invalidate a password you've written down.


Sharing files with the Mac

Everything in ~/vm-share on the host (or whatever SHARE_DIR points at) appears inside the guest at:

/var/mnt/hostshare

It is mounted over virtio-9p by a pair of systemd units, and it's an automount: nothing is mounted until something first touches the path, so a boot where the share isn't available costs you one missing directory rather than a hung startup. A /mnt/hostshare symlink is created too, where the system allows it.

The mountpoint is under /var rather than /mnt because Fedora Atomic's root filesystem is read-only — /mnt cannot be given new subdirectories.

Check it from the host:

./swaybox.sh ssh 'findmnt -t 9p /var/mnt/hostshare'

Setting up your actual workload

The script provisions the user, SSH, the share, and the disk. Installing what you actually work in is manual and one-time.

Fedora Sway Atomic is an image-based (bootc/rpm-ostree) system, so dnf install on the host system is not how software gets installed. Instead:

Put everything in one toolbox

toolbox create
toolbox enter
# inside the toolbox, dnf works normally:
sudo dnf install nodejs chromium

Install both your terminal tooling and Chromium inside the same toolbox (Node is there for Node-based CLI tools; swap in whatever you actually use). This is deliberate: a toolbox shares the host network namespace and your real $HOME, so your dev server, your editor, and your browser all agree about what localhost means and where files are. The Flatpak Chromium has its own filesystem view and reintroduces exactly that friction.

Chromium on Sway

Launch it with the Wayland backend so it renders natively instead of through XWayland:

chromium --ozone-platform=wayland

Recent versions may detect this on their own.

If Chromium fails to start with an error about being unable to move to a new namespace, its sandbox is colliding with the container. The usual workaround is --no-sandbox. That is an acceptable trade for a browser you use to debug localhost, but it is a genuine reduction in browser security — don't use that profile for general web browsing.

Installing outside a container

rpm-ostree install <pkg> layers a package onto the base image and requires a reboot. Reserve it for things that genuinely must exist outside a container, like drivers or a VPN client — every layered package slows down future image updates.

A note on swap

Fedora enables zram (compressed swap in RAM) by default, and in a 3.5 GB guest that is doing a lot of work — it's a large part of why Chromium plus a toolbox fits at all. Leave it on, and don't replace it with a disk swapfile.


Graphics

By default the guest uses a software-rendered virtio GPU. It works out of the box, but compositing and scrolling will feel slower than a native app.

Homebrew's QEMU ships without OpenGL support, so hardware acceleration needs a patched build:

brew tap knazarov/qemu-virgl
brew install knazarov/qemu-virgl/qemu-virgl
GL=1 ./swaybox.sh run

GL=1 against stock Homebrew QEMU will not work.


Networking

The VM uses QEMU's usermode (SLIRP) networking: outbound connections and DNS work normally, including through a VPN, but the guest is not directly reachable from your LAN. The one forwarded port is SSH, on localhost:2222.

This is not an oversight — the faster vmnet backends require the com.apple.vm.networking entitlement, and Homebrew's QEMU binary isn't signed with it. To expose another port, add a hostfwd rule in net_args().


Disk space

DISK_SIZE is a ceiling, not an allocation: the qcow2 file grows as the guest writes, so a 64 GB setting doesn't cost 64 GB up front. It does eventually, though, if you fill it.

Budget generously. The deployed system image is around 10 GB, bootc keeps the previous deployment after an update (so plan for two), and your toolbox — Node, Chromium, container images — lands on the same filesystem.

DISK_SIZE only applies when the disk is built. To grow a disk that already exists:

./swaybox.sh stop
qemu-img resize ~/vms/fedora-sway/disk.qcow2 96G   # can only grow, never shrink
./swaybox.sh run-bg
./swaybox.sh setup                                  # grows the filesystem to match

Check the result with ./swaybox.sh ssh 'df -h /'.


How create works, and why it's unusual

Sway Atomic ships no Anaconda installer ISO for Apple Silicon — only a bootc container image. So there is no installer to boot.

Instead, create runs bootc-image-builder under Podman to convert the container image directly into a bootable qcow2, driven by a generated config that injects your user, SSH key, console password, and wheel membership. It then resizes the image, boots it once headlessly, and provisions the guest over SSH: passwordless sudo, the host share automount, and growing the root filesystem into the resized disk.

Before that, create builds a small derived image — one layer on top of the base that runs systemctl enable sshd.socket. The base image ships sshd split into per-transport units with the TCP one disabled, so without this nothing listens on port 22 and the port forward has nothing to reach. Customising the image as an image is the bootc-native way to do this; the equivalent blueprint-level settings do not work here. The derived image is built locally as localhost/swaybox-base:<release> and create verifies the unit really is enabled in it before spending time on the disk build.

This is why create needs Podman and switches it to rootful mode, and why it takes a while.


Troubleshooting

Provisioning is written to fail loudly. If a step fails you should get a named reason rather than a VM that looks fine and isn't. Re-run ./swaybox.sh setup after fixing something — it's safe to run repeatedly.

create finished but nothing is provisioned / SSH never came up. Almost always this means the guest has no TCP SSH listener. Fedora splits sshd into per-transport units and ships the TCP one disabled, so the image comes up with only sshd-unix-local.socket (AF_UNIX) and sshd-vsock.socket (AF_VSOCK) active — neither accepts TCP, so port 22 stays closed and the port forward has nothing to reach. create now enables sshd.socket when it builds the image; a VM built before that fix needs it turned on by hand.

Get a console. The simplest route is a native window rather than VNC, which avoids macOS Screen Sharing's password prompt entirely (QEMU's VNC server here has no password set, so that prompt cannot be satisfied):

./swaybox.sh stop && ./swaybox.sh run

Log in as user with ./swaybox.sh password, then:

systemctl list-unit-files | grep -i ssh   # what exists, and its enabled state
sudo systemctl enable --now sshd.socket   # the TCP listener
ss -ltnp | grep :22                       # confirm something is listening

Then run ./swaybox.sh setup from the Mac.

The failure path in create also prints the tail of the guest serial console ($VM_DIR/console.log) and QEMU's own log ($VM_DIR/qemu.log), both captured on every boot, so a guest that never finishes booting isn't a black box.

sudo asks for a password over SSH. An SSH session has no terminal, so sudo can't prompt, and unattended setup fails at its first line. setup bootstraps passwordless sudo using the saved console password. Verify with:

./swaybox.sh ssh 'sudo -n true; echo exit=$?'

"Host key verification failed" / "REMOTE HOST IDENTIFICATION HAS CHANGED". The guest generates a new host key every time you recreate. The script's own SSH calls discard host keys for this reason. If you're connecting by hand, drop the stale entry:

ssh-keygen -R '[localhost]:2222'

The disk is smaller inside the guest than DISK_SIZE. Growing the qcow2 file doesn't grow the filesystem in it. Run ./swaybox.sh setup, then check df -h /.

The share isn't there. It's an automount, so it won't appear until something accesses the path. Try ls /var/mnt/hostshare first, then systemctl status var-mnt-hostshare.mount and journalctl -u var-mnt-hostshare.mount.

The VM won't shut down. stop escalates automatically: SSH systemctl poweroff, then a QMP powerdown over the monitor socket, then kill -9.


Where state lives

Nothing is stored in this repo. Everything lives in $VM_DIR (~/vms/fedora-sway by default):

File Purpose
disk.qcow2 The VM's disk
.password Console password, mode 0600
qemu.pid PID file, used to tell whether the VM is running
qmp.sock QEMU monitor socket, used by stop
console.log Guest serial console from the current boot
qemu.log QEMU's own log from the current boot
qemu-stderr.log QEMU's startup output — where launch failures land
build/ bootc-image-builder config and output

destroy removes only the disk. It keeps the pulled container images and your password, so recreate is much faster than the first create.


Status and limitations

  • macOS only. The script can't run on Linux — it assumes hvf acceleration, the Cocoa display backend, and Homebrew's firmware paths.
  • Apple Silicon is the primary target; x86_64 is handled but far less exercised.
  • Paths containing spaces will break QEMU argument assembly.
  • Exercised end to end on an M2 against Fedora 44 Sway Atomic: create from scratch through to a provisioned, running VM. Other hosts and releases are untried, and FEDORA_RELEASE in particular is the knob most likely to move under you — the sshd unit layout and the composefs root are both things this release does one specific way.
  • Provisioning is written to fail loudly. If create or setup misbehaves, the error names the failing step, and create additionally prints the guest serial console and QEMU's log — that output is the thing to report.