diff --git a/build-individual.nu b/build-individual.nu
index 1d20a74..3d53f3f 100644
--- a/build-individual.nu
+++ b/build-individual.nu
@@ -7,7 +7,7 @@ let images = ls modules | each { |moduleDir|
     cd $moduleDir.name
 
     # module is unversioned
-    if ($"($moduleDir.name | path basename).sh" | path exists) {
+    if (glob $"($moduleDir.name | path basename).{sh,nu}" | any { path exists }) {
 
         print $"(ansi cyan)Found(ansi reset) (ansi cyan_bold)unversioned(ansi reset) (ansi cyan)module:(ansi reset) ($moduleDir.name | path basename)"
 
diff --git a/build-unified.nu b/build-unified.nu
index baccabe..3faddd0 100644
--- a/build-unified.nu
+++ b/build-unified.nu
@@ -9,7 +9,7 @@ mkdir ./modules-latest
 ls modules | each { |moduleDir|
 
     # module is unversioned
-    if ($"($moduleDir.name)/($moduleDir.name | path basename).sh" | path exists) {
+    if (glob $"($moduleDir.name)/($moduleDir.name | path basename).{sh,nu}" | any { path exists }) {
 
         print $"(ansi cyan)Found(ansi reset) (ansi cyan_bold)unversioned(ansi reset) (ansi cyan)module:(ansi reset) ($moduleDir.name | path basename)"
 
@@ -56,4 +56,4 @@ let digest = (
 print $"(ansi cyan)Signing image:(ansi reset) ($env.REGISTRY)/modules@($digest)"
 cosign sign -y --key env://COSIGN_PRIVATE_KEY $"($env.REGISTRY)/modules@($digest)"
 
-print $"(ansi green_bold)DONE!(ansi reset)"
\ No newline at end of file
+print $"(ansi green_bold)DONE!(ansi reset)"
diff --git a/modules.json b/modules.json
index 66afaa8..b597ad2 100644
--- a/modules.json
+++ b/modules.json
@@ -10,6 +10,7 @@
   "https://raw.githubusercontent.com/blue-build/modules/main/modules/gschema-overrides/module.yml",
   "https://raw.githubusercontent.com/blue-build/modules/main/modules/justfiles/module.yml",  
   "https://raw.githubusercontent.com/blue-build/modules/main/modules/rpm-ostree/module.yml",
+  "https://raw.githubusercontent.com/blue-build/modules/main/modules/dnf/module.yml",
   "https://raw.githubusercontent.com/blue-build/modules/main/modules/initramfs/module.yml",
   "https://raw.githubusercontent.com/blue-build/modules/main/modules/script/module.yml",
   "https://raw.githubusercontent.com/blue-build/modules/main/modules/signing/module.yml",
diff --git a/modules/dnf/README.md b/modules/dnf/README.md
new file mode 100644
index 0000000..90f6b5b
--- /dev/null
+++ b/modules/dnf/README.md
@@ -0,0 +1,233 @@
+# **`dnf` Module**
+
+The `dnf` module offers pseudo-declarative package and repository management using [`dnf5`](https://github.com/rpm-software-management/dnf).
+
+## Features
+
+This module is capable of:
+
+- Repository Management
+  - Enabling/disabling COPR repos
+  - Adding repo files via url or local files
+  - Removing repos by specifying the repo name
+  - Automatically cleaning up any repos added in the module
+  - Adding keys for repos via url or local files
+  - Adding non-free repos like `rpmfusion` and `negativo17`
+- Package Management
+  - Installing packages from RPM urls, local RPM files, or package repositories
+  - Installing packages from a specific repository
+  - Removing packages
+  - Replacing installed packages with versions from another repository
+- Optfix
+  - Setup symlinks to `/opt/` to allow certain packages to install
+
+## Repository Management
+
+### Add Repository Files
+
+- Add repos from
+  - any `https://` or `http://` URL
+  - any `.repo` files located in `./files/dnf/` of your image repo
+- If the OS version is included in the file name or URL, you can substitute it with the `%OS_VERSION%` magic string
+  - The version is gathered from the `VERSION_ID` field of `/usr/lib/os-release`
+
+```yaml
+type: dnf
+repos:
+  files:
+    - https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo
+    - custom-file.repo # file path for /files/dnf/custom-file.repo
+```
+
+### Add COPR Repositories
+
+- [COPR](https://copr.fedorainfracloud.org/) contains software repositories maintained by fellow Fedora users
+
+```yaml
+type: dnf
+repos:
+  copr:
+    - atim/starship
+    - trixieua/mutter-patched
+```
+
+### Disable/Enable Repositories
+
+```yaml
+type: dnf
+repos:
+  files:
+    add:
+      - repo1
+      - repo2
+    remove:
+      - repo3
+  copr:
+    enable:
+      - ryanabx/cosmic-epoch
+    disable:
+      - kylegospo/oversteer
+```
+
+### Add Repository Keys
+
+```yaml
+type: dnf
+repos:
+  keys:
+    - https://example.com/repo-1.asc
+    - key2.asc
+```
+
+## Package Management
+
+### Packages from Any Repository
+
+```yaml
+type: dnf
+install:
+  packages:
+    - package-1
+    - package-2
+```
+
+### Packages from URL or File
+
+- If the OS version is included in the file name or URL, you can substitute it with the `%OS_VERSION%` magic string
+  - The version is gathered from the `VERSION_ID` field of `/usr/lib/os-release`
+
+```yaml
+type: dnf
+install:
+  packages:
+    - https://example.com/package-%OS_VERSION%.rpm
+    - custom-file.rpm # install files/dnf/custom-file.rpm from the image repository
+```
+
+### Install Packages from Specific Repositories
+
+- Set `repo` to the name of the RPM repository, not the name or URL of the repo file
+
+```yaml
+type: dnf
+install:
+  packages:
+    - repo: copr:copr.fedorainfracloud.org:custom-user:custom-repo
+      packages:
+        - package-1
+```
+
+### Remove Packages
+
+```yaml
+type: dnf
+remove:
+  packages:
+    - package-1
+    - package-2
+```
+
+### Install Package Groups
+
+- See list of all package groups by running `dnf5 group list --hidden` on a live system
+- Set the option `with-optional` to `true` to enable installation of optional packages in package groups
+
+```yaml
+type: dnf
+group-install:
+  with-optional: true
+  packages:
+    - de-package-1
+    - wm-package-2
+```
+
+### Remove Package Groups
+```yaml
+type: dnf
+group-remove:
+  packages:
+    - de-package-2
+```
+
+### Replace Packages
+- You can specify one or more packages that will be swapped from another repo
+- This process uses `distro-sync` to perform this operation
+- All packages not specifying `old:` and `new:` will be swapped in a single transaction
+
+```yaml
+type: dnf
+replace:
+  - from-repo: copr:copr.fedorainfracloud.org:custom-user:custom-repo
+    packages:
+      - package-1
+```
+
+- If a package has a different name in another repo, you can use the `old:` and `new:` properties
+- This process uses `swap` to perform this operation for each set
+- This process is ran before `distro-sync`
+
+```yaml
+type: dnf
+replace:
+  - from-repo: repo-1
+    packages:
+      - old: old-package-2
+        new: new-package-2
+```
+
+### Installation options
+
+The following options can specified in the package installation, group installation, and package replacement sections.
+
+- `install-weak-deps` enables installation of the weak dependencies of RPMs
+  - Enabled by default
+  - Corresponds to the [`--setopt=install_weak_deps=True` / `--setopt=install_weak_deps=False`](https://dnf5.readthedocs.io/en/latest/dnf5.conf.5.html#install-weak-deps-options-label) flag
+- `skip-unavailable` enables skipping packages unavailable in repositories without erroring out
+  - Disabled by default
+  - Corresponds to the [`--skip-unavailable`](https://dnf5.readthedocs.io/en/latest/commands/install.8.html#options) flag
+- `skip-broken` enables skipping broken packages without erroring out
+  - Disabled by default
+  - Corresponds to the [`--skip-broken`](https://dnf5.readthedocs.io/en/latest/commands/install.8.html#options) flag
+- `allow-erasing` allows removing packages in case of dependency problems during package installation
+  - Disabled by default
+  - Corresponds to the [`--allowerasing`](https://dnf5.readthedocs.io/en/latest/commands/install.8.html#options) flag
+
+```yaml
+type: dnf
+install:
+  skip-unavailable: true
+  packages:
+    ...
+group-install:
+  skip-unavailable: true
+  packages:
+    ...
+replace:
+  - from-repo: repo-1
+    allow-erasing: true
+    packages:
+      ...
+```
+
+## Optfix
+
+- Optfix is a script used to work around problems with certain packages that install into `/opt/`
+  - These issues are caused by Fedora Atomic storing `/opt/` at the location `/var/opt/` by default, while `/var/` is only writeable on a live system
+  - The script works around these issues by moving the folder to `/usr/lib/opt/` and creating the proper symlinks at runtime
+- Specify a list of folders inside `/opt/`
+
+```yaml
+type: dnf
+optfix:
+  - brave.com
+  - foldername
+```
+
+## Known issues
+
+Replacing the kernel with the `dnf` module is not done cleanly at the moment & some remaints of old kernel will be present.  
+Please use the `rpm-ostree` module for this purpose until this `dnf` behavior is fixed.
+
+## Note
+
+This documentation page uses the installation of the Brave Browser as an example of a package that required a custom repository, with a custom key, and an optfix configuration to install properly. This is not an official endorsement of the Brave Browser by the BlueBuild project.
diff --git a/modules/dnf/bluebuild-optfix.service b/modules/dnf/bluebuild-optfix.service
new file mode 100644
index 0000000..6470559
--- /dev/null
+++ b/modules/dnf/bluebuild-optfix.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Create symbolic links for directories in /usr/lib/opt/ to /var/opt/
+After=multi-user.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/libexec/bluebuild/optfix.sh
+RemainAfterExit=no
+
+[Install]
+WantedBy=default.target
diff --git a/modules/dnf/dnf.nu b/modules/dnf/dnf.nu
new file mode 100644
index 0000000..0037375
--- /dev/null
+++ b/modules/dnf/dnf.nu
@@ -0,0 +1,887 @@
+#!/usr/bin/env nu
+
+const NEGATIVO = 'negativo17'
+const NEGATIVO_URL = 'https://negativo17.org/repos/fedora-negativo17.repo'
+const RPMFUSION = 'rpmfusion'
+
+# Handles installing necessary plugins for repo management.
+def check_dnf5_plugins []: nothing -> nothing {
+  if (^rpm -q dnf5-plugins | complete).exit_code != 0 {
+    print $'(ansi yellow1)Required dnf5 plugins are not installed. Installing plugins(ansi reset)'
+
+    install_pkgs { packages: [dnf5-plugins] }
+  }
+}
+
+# Handle adding/removing repo files and COPR repos.
+# 
+# This command returns an object containing the repos
+# that were added to allow for cleaning up afterwards.
+def repos [$repos: record]: nothing -> record {
+  let repos = $repos
+    | default [] keys
+
+  let cleanup_repos = match $repos.files? {
+    # Add repos if it's a list
+    [..$files] => {
+      add_repos ($files | default [])
+    }
+    # Add and remove repos
+    {
+      add: [..$add]
+      remove: [..$remove]
+    } => {
+      let repos = add_repos ($add | default [])
+      remove_repos ($remove | default [])
+      $repos
+    }
+    # Add repos
+    { add: [..$add] } => {
+      add_repos ($add | default [])
+    }
+    # Remove repos
+    { remove: [..$remove] } => {
+      remove_repos ($remove | default [])
+      []
+    }
+    _ => []
+  }
+
+  let cleanup_coprs = match $repos.copr? {
+    # Enable repos if it's a list
+    [..$coprs] => {
+      add_coprs ($coprs | default [])
+    }
+    # Enable and disable repos
+    {
+      enable: [..$enable]
+      disable: [..$disable]
+    } => {
+      let coprs = add_coprs ($enable | default [])
+      disable_coprs ($disable | default [])
+      $coprs
+    }
+    # Enable repos
+    { enable: [..$enable] } => {
+      add_coprs ($enable | default [])
+    }
+    # Disable repos
+    { disable: [..$disable] } => {
+      disable_coprs ($disable | default [])
+      []
+    }
+    _ => []
+  }
+
+  nonfree_repos $repos.nonfree?
+  add_keys $repos.keys
+
+  {
+    copr: $cleanup_coprs
+    files: $cleanup_repos
+  }
+}
+
+# Setup nonfree repos for rpmfusion or negativo17-multimedia.
+def nonfree_repos [repo_type?: string]: nothing -> list<string> {
+  check_dnf5_plugins
+
+  match $repo_type {
+    $repo if $repo == $RPMFUSION => {
+      disable_negativo
+      enable_rpmfusion
+    }
+    $repo if $repo == $NEGATIVO => {
+      disable_rpmfusion
+      enable_negativo
+    }
+    null => [],
+    _ => {
+      error make {
+        msg: $"The only valid values are '($NEGATIVO)' and '($RPMFUSION)'"
+        label: {
+          text: 'Passed in value'
+          span: (metadata $repo_type).span
+        }
+      }
+    }
+  }
+}
+
+# Enable rpmfusion repos
+#
+# See https://rpmfusion.org/Configuration
+def enable_rpmfusion []: nothing -> nothing {
+  const CISCO_REPO = 'fedora-cisco-openh264'
+
+  print $'(ansi green)Enabling rpmfusion repos(ansi reset)'
+
+  mut repos = []
+
+  if (^rpm -q rpmfusion-free-release | complete).exit_code != 0 {
+    $repos = $repos | append $'https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-($env.OS_VERSION).noarch.rpm'
+  }
+
+  if (^rpm -q rpmfusion-nonfree-release | complete).exit_code != 0 {
+    $repos = $repos | append $'https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-($env.OS_VERSION).noarch.rpm'
+  }
+
+  install_pkgs { packages: $repos }
+
+  print $"(ansi green)Enabling '(ansi cyan)($CISCO_REPO)(ansi green)' repo for RPMFusion compatibility(ansi reset)"
+  try {
+    ^dnf5 config-manager setopt $'($CISCO_REPO).enabled=1'
+  } catch {
+    exit 1
+  }
+}
+
+# Disable rpmfusion repos
+def disable_rpmfusion []: nothing -> nothing {
+  print $'(ansi green)Removing rpmfusion repos(ansi reset)'
+
+  mut repos = []
+
+  if (^rpm -q rpmfusion-free-release | complete).exit_code == 0 {
+    $repos = $repos | append 'rpmfusion-free-release'
+  }
+
+  if (^rpm -q rpmfusion-nonfree-release | complete).exit_code == 0 {
+    $repos = $repos | append 'rpmfusion-nonfree-release'
+  }
+
+  remove_pkgs { packages: $repos }
+}
+
+def negativo_repo_list []: nothing -> list<path> {
+  try {
+    ^dnf5 -y repo list --all --json | from json
+  } catch {
+    exit 1
+  }
+    | find negativo17
+    | get id
+    | ansi strip
+    | par-each {|repo|
+      try {
+        ^dnf5 -y repo info $repo --all --json | from json
+      } catch {
+        exit 1
+      }
+    }
+    | flatten
+    | get id
+    | uniq
+}
+
+# Enable negativo17-multimedia repos
+def enable_negativo []: nothing -> nothing {
+  print $'(ansi green)Enabling negativo17 repos(ansi reset)'
+
+  let current_repo_list = negativo_repo_list
+
+  if ($current_repo_list | is-not-empty) {
+    print $'(ansi green)Cleaning up existing negativo17 repos(ansi reset)'
+    remove_repos $current_repo_list
+  }
+  add_repos [$NEGATIVO_URL]
+
+  try {
+    ^dnf5 repo list --all --json
+  } catch {
+    exit 1
+  }
+    | from json
+    | find negativo17
+    | get id
+    | ansi strip
+    | each {|id|
+      [$'($id).enabled=1' $'($id).priority=90']
+    }
+    | flatten
+    | try {
+      ^dnf5 -y config-manager setopt ...($in)
+    } catch {
+      exit 1
+    }
+}
+
+# Disable negativo17-multimedia repos
+def disable_negativo []: nothing -> nothing {
+  print $'(ansi green)Disabling negativo17 repos(ansi reset)'
+
+  remove_repos (negativo_repo_list)
+}
+
+# Adds a list of repo files for `dnf` to use
+# for installing packages.
+#
+# Returns a list of IDs of the repos added
+def add_repos [$repos: list]: nothing -> list<string> {
+  check_dnf5_plugins
+
+  if ($repos | is-not-empty) {
+    print $'(ansi green)Adding repositories:(ansi reset)'
+
+    # Substitute %OS_VERSION% & remove newlines/whitespaces from all repo entries
+    let repos = $repos
+      | each {
+        str replace --all '%OS_VERSION%' $env.OS_VERSION
+          | str trim
+      }
+    $repos
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    for $repo in $repos {
+      let repo_path = [$env.CONFIG_DIRECTORY dnf $repo] | path join
+      let repo = if ($repo | str starts-with 'https://') or ($repo | str starts-with 'http://') {
+        print $"Adding repository URL: (ansi cyan)'($repo)'(ansi reset)"
+        $repo
+      } else if ($repo | str ends-with '.repo') and ($repo_path | path exists) {
+        print $"Adding repository file: (ansi cyan)'($repo)'(ansi reset)"
+        $repo_path
+      } else {
+        return (error make {
+          msg: $"(ansi red)Unrecognized repo (ansi cyan)'($repo)'(ansi reset)"
+          label: {
+            span: (metadata $repo).span
+            text: 'Found in config'
+          }
+        })
+      }
+
+      try {
+        ^dnf5 -y config-manager addrepo --overwrite --from-repofile $repo
+      } catch {
+        exit 1
+      }
+    }
+  }
+
+  # Get a list of paths of all new repo files added
+  let repo_files = $repos
+    | each {|repo|
+      [/ etc yum.repos.d ($repo | path basename)] | path join 
+    }
+
+  # Get a list of info for every repo installed
+  let repo_info = try {
+    ^dnf5 repo list --all --json
+  } catch {
+    exit 1
+  }
+    | from json
+    | get id
+    | par-each {|repo|
+      try {
+        ^dnf5 repo info --json $repo
+      } catch {
+        exit 1
+      }
+        | from json
+    }
+    | flatten
+
+  # Return the IDs of all repos that were added
+  let repo_ids = $repo_info
+    | filter {|repo|
+      $repo.repo_file_path in $repo_files
+    }
+    | get id
+
+  $repo_ids
+    | each {
+      $'($in).enabled=1'
+    }
+    | try {
+      ^dnf5 -y config-manager setopt ...($in)
+    } catch {
+      exit 1
+    }
+
+  $repo_ids
+}
+
+# Remove a list of repos. The list must be the IDs of the repos.
+def remove_repos [$repos: list]: nothing -> nothing {
+  if ($repos | is-not-empty) {
+    print $'(ansi green)Removing repositories:(ansi reset)'
+    let repos = $repos | str trim
+    $repos
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    $repos
+      | par-each {|repo|
+        try {
+          ^dnf5 -y repo info $repo --all --json | from json
+        } catch {
+          exit 1
+        }
+      }
+      | flatten
+      | get repo_file_path
+      | uniq
+      | each {|file|
+        print $"Removing repo file '(ansi cyan)($file)(ansi reset)'"
+        rm -f $file
+      }
+  }
+}
+
+# Checks to see if the string passed in is
+# a COPR repo string. Will error if it isn't
+def check_copr []: string -> string {
+  let is_copr = ($in | split row / | length) == 2
+
+  if not $is_copr {
+    return (error make {
+      msg: $"(ansi red)The string '(ansi cyan)($in)(ansi red)' is not recognized as a COPR repo(ansi reset)"
+      label: {
+        span: (metadata $is_copr).span
+        text: 'Checks if string is a COPR repo'
+      }
+    })
+  }
+
+  $in
+}
+
+# Enable a list of COPR repos. The COPR repo ID has a '/' in the name.
+#
+# This will error if a COPR repo ID is invalid.
+def add_coprs [$copr_repos: list]: nothing -> list<string> {
+  check_dnf5_plugins
+
+  if ($copr_repos | is-not-empty) {
+    print $'(ansi green)Adding COPR repositories:(ansi reset)'
+    $copr_repos
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    for $copr in $copr_repos {
+      print $"Adding COPR repository: (ansi cyan)'($copr)'(ansi reset)"
+      try {
+        ^dnf5 -y copr enable ($copr | check_copr)
+      } catch {
+        exit 1
+      }
+    }
+  }
+  $copr_repos
+}
+
+# Disable a list of COPR repos. The COPR repo ID has a '/' in the name.
+#
+# This will error if a COPR repo ID is invalid.
+def disable_coprs [$copr_repos: list]: nothing -> nothing {
+  check_dnf5_plugins
+
+  if ($copr_repos | is-not-empty) {
+    print $'(ansi green)Adding COPR repositories:(ansi reset)'
+    $copr_repos
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    for $copr in $copr_repos {
+      print $"Disabling COPR repository: (ansi cyan)'($copr)'(ansi reset)"
+      try {
+        ^dnf5 -y copr disable ($copr| check_copr)
+      } catch {
+        exit 1
+      }
+    }
+  }
+}
+
+# Add a list of keys for integrity checking repos.
+def add_keys [$keys: list]: nothing -> nothing {
+  if ($keys | is-not-empty) {
+    print $'(ansi green)Adding keys:(ansi reset)'
+    let keys = $keys
+      | str replace --all '%OS_VERSION%' $env.OS_VERSION
+      | str trim
+      | each {|key|
+        let key = if ($key | str starts-with 'https://') or ($key | str starts-with 'http://') {
+          $key
+        } else {
+          [$env.CONFIG_DIRECTORY dnf $key] | path join
+        }
+        print $'- (ansi cyan)($key)(ansi reset)'
+        $key
+      }
+
+    for $key in $keys {
+      let key = $key
+        | str replace --all '%OS_VERSION%' $env.OS_VERSION
+        | str trim
+
+      try {
+        ^rpm --import $key
+      } catch {
+        exit 1
+      }
+    }
+  }
+}
+
+# Setup /opt directory symlinks to allow certain packages to install.
+#
+# Each entry must be the directory name that the application expects
+# to install into /opt. A systemd unit will be installed to setup
+# symlinks on boot of the OS.
+def run_optfix [$optfix_pkgs: list]: nothing -> nothing {
+  const LIB_EXEC_DIR = '/usr/libexec/bluebuild'
+  const SYSTEMD_DIR = '/etc/systemd/system'
+  const MODULE_DIR = '/tmp/modules/dnf'
+  const LIB_OPT_DIR = '/usr/lib/opt'
+  const VAR_OPT_DIR = '/var/opt'
+  const OPTFIX_SCRIPT = 'optfix.sh'
+  const SERV_UNIT = 'bluebuild-optfix.service'
+
+  if ($optfix_pkgs | is-not-empty) {
+    if not ($LIB_EXEC_DIR | path join $OPTFIX_SCRIPT | path exists) {
+      mkdir $LIB_EXEC_DIR
+      cp ($MODULE_DIR | path join $OPTFIX_SCRIPT) $'($LIB_EXEC_DIR)/'
+
+      try {
+        ^chmod +x $'($LIB_EXEC_DIR | path join $OPTFIX_SCRIPT)'
+      } catch {
+        exit 1
+      }
+    }
+
+    if not ($SYSTEMD_DIR | path join $SERV_UNIT | path exists) {
+      cp ($MODULE_DIR | path join $SERV_UNIT) $'($SYSTEMD_DIR)/'
+
+      try {
+        ^systemctl enable $SERV_UNIT
+      } catch {
+        exit 1
+      }
+    }
+
+    print $"(ansi green)Creating symlinks to fix packages that install to /opt:(ansi reset)"
+    $optfix_pkgs
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    mkdir $VAR_OPT_DIR
+    try {
+      ^ln -snf $VAR_OPT_DIR /opt
+    } catch {
+      exit 1
+    }
+
+    for $opt in $optfix_pkgs {
+      let lib_dir = [$LIB_OPT_DIR $opt] | path join
+      let var_opt_dir = [$VAR_OPT_DIR $opt] | path join
+
+      mkdir $lib_dir
+
+      try {
+        ^ln -sf $lib_dir $var_opt_dir
+      } catch {
+        exit 1
+      }
+
+      print $"Created symlinks for '(ansi cyan)($opt)(ansi reset)'"
+    }
+  }
+}
+
+# Remove group packages.
+def group_remove [remove: record]: nothing -> nothing {
+  let remove_list = $remove
+    | default [] packages
+    | get packages
+
+  if ($remove_list | is-not-empty) {
+    print $'(ansi green)Removing group packages:(ansi reset)'
+    $remove_list
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    try {
+      ^dnf5 -y group remove ...($remove_list)
+    } catch {
+      exit 1
+    }
+  }
+}
+
+# Install group packages.
+def group_install [install: record]: nothing -> nothing {
+  let install = $install
+    | default false with-optional
+    | default [] packages
+  let install_list = $install
+    | get packages
+    | each { str trim }
+
+  if ($install_list | is-not-empty) {
+    print $'(ansi green)Installing group packages:(ansi reset)'
+    $install_list
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    mut args = $install | install_args
+
+    if $install.with-optional {
+      $args = $args | appent '--with-optional'
+    }
+
+    try {
+      (^dnf5
+        -y
+        ($install | weak_arg)
+        group
+        install
+        ...($args)
+        ...($install_list))
+    } catch {
+      exit 1
+    }
+  }
+}
+
+# Remove packages.
+def remove_pkgs [remove: record]: nothing -> nothing {
+  let remove = $remove
+    | default [] packages
+    | default true auto-remove
+
+  if ($remove.packages | is-not-empty) {
+    print $'(ansi green)Removing packages:(ansi reset)'
+    $remove.packages
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    mut args = []
+
+    if not $remove.auto-remove {
+      $args = $args | append '--no-autoremove'
+    }
+
+    try {
+      ^dnf5 -y remove ...($args) ...($remove.packages)
+    } catch {
+      exit 1
+    }
+  }
+}
+
+# Build up args to use on `dnf`
+def install_args [
+  --global-config: record
+  ...filter: string
+]: record -> list<string> {
+  let install = $in
+    | default (
+      $global_config.skip-unavailable?
+        | default false
+    ) skip-unavailable
+    | default (
+      $global_config.skip-broken?
+        | default false
+    ) skip-broken
+    | default (
+      $global_config.allow-erasing?
+        | default false
+    ) allow-erasing
+  mut args = []
+  let check_filter = {|arg|
+    let arg_exists = ($arg in $install)
+    if ($filter | is-empty) {
+      $arg_exists and ($install | get $arg)
+    } else {
+      $arg_exists and ($arg in $filter) and ($install | get $arg)
+    }
+  }
+
+  if (do $check_filter 'skip-unavailable') {
+    $args = $args | append '--skip-unavailable'
+  }
+
+  if (do $check_filter 'skip-broken') {
+    $args = $args | append '--skip-broken'
+  }
+
+  if (do $check_filter 'allow-erasing') {
+    $args = $args | append '--allowerasing'
+  }
+
+  $args
+}
+
+# Generate a weak deps argument
+def weak_arg [
+  --global-config: record
+]: record -> string {
+  let install =
+    | default (
+      $global_config.install-weak-deps?
+        | default true
+    ) install-weak-deps
+
+  if $install.install-weak-deps {
+    '--setopt=install_weak_deps=True'
+  } else {
+    '--setopt=install_weak_deps=False'
+  }
+}
+
+# Install packages.
+#
+# You can specify a list of packages to install, and you can
+# specify a list of packages for a specific repo to install.
+def install_pkgs [install: record]: nothing -> nothing {
+  let install = $install
+    | default [] packages
+
+  # Gather lists of the various ways a package is installed
+  # to report back to the user.
+  let install_list = $install.packages
+    | filter {|pkg|
+      ($pkg | describe) == 'string'
+    }
+    | str replace --all '%OS_VERSION%' $env.OS_VERSION
+    | str trim
+  let http_list = $install_list
+    | filter {|pkg|
+      ($pkg | str starts-with 'https://') or ($pkg | str starts-with 'http://')
+    }
+  let local_list = $install_list
+    | each {|pkg|
+      [$env.CONFIG_DIRECTORY dnf $pkg] | path join
+    }
+    | filter {|pkg|
+      ($pkg | path exists)
+    }
+  let normal_list = $install_list
+    | filter {|pkg|
+      not (
+        ($pkg | str starts-with 'https://') or ($pkg | str starts-with 'http://')
+      ) and not (
+        [$env.CONFIG_DIRECTORY dnf $pkg]
+          | path join
+          | path exists
+      )
+    }
+
+  if ($install_list | is-not-empty) {
+    if ($http_list | is-not-empty) {
+      print $'(ansi green)Installing packages directly from URL:(ansi reset)'
+      $http_list
+        | each {
+          print $'- (ansi cyan)($in)(ansi reset)'
+        }
+    }
+
+    if ($local_list | is-not-empty) {
+      print $'(ansi green)Installing local packages:(ansi reset)'
+      $local_list
+        | each {
+          print $'- (ansi cyan)($in)(ansi reset)'
+        }
+    }
+
+    if ($normal_list | is-not-empty) {
+      print $'(ansi green)Installing packages:(ansi reset)'
+      $normal_list
+        | each {
+          print $'- (ansi cyan)($in)(ansi reset)'
+        }
+    }
+
+    try {
+      (^dnf5
+        -y
+        ($install | weak_arg)
+        install
+        ...($install | install_args)
+        ...($http_list)
+        ...($local_list)
+        ...($normal_list))
+    } catch {
+      exit 1
+    }
+  }
+
+  # Get all the entries that have a repo specified.
+  let repo_install_list = $install.packages
+    | filter {|pkg|
+      'repo' in $pkg and 'packages' in $pkg
+    }
+
+  for $repo_install in $repo_install_list {
+    let repo = $repo_install.repo
+    let packages = $repo_install.packages
+
+    print $'(ansi green)Installing packages from repo (ansi cyan)($repo)(ansi green):(ansi reset)'
+    $packages
+      | each {
+        print $'- (ansi cyan)($in)(ansi reset)'
+      }
+
+    try {
+      (^dnf5
+        -y
+        ($repo_install | weak_arg --global-config $install)
+        install
+        --repoid
+        $repo
+        ...($repo_install | install_args --global-config $install)
+        ...($packages))
+    } catch {
+      exit 1
+    }
+  }
+}
+
+# Perform a replace operation for a list of packages that
+# you want to replace from a specific repo.
+def replace_pkgs [replace_list: list]: nothing -> nothing {
+  let check = {|item|
+    'old' in $item and 'new' in $item
+  }
+
+  if ($replace_list | is-not-empty) {
+    for $replacement in $replace_list {
+      let replacement = $replacement
+        | default [] packages
+
+      if ($replacement.packages | is-not-empty) {
+        let has_from_repo = 'from-repo' in $replacement
+
+        if not $has_from_repo {
+          return (error make {
+            msg: $"(ansi red)A value is expected in key 'from-repo'(ansi reset)"
+            label: {
+              span: (metadata $replacement).span
+              text: "Checks for 'from-repo' property"
+            }
+          })
+        }
+
+        let from_repo = $replacement
+          | get from-repo
+
+        let swap_packages = $replacement.packages
+          | filter $check
+        let sync_packages = $replacement.packages
+          | filter {
+            not (do $check $in)
+          }
+
+        if ($swap_packages | is-not-empty) {
+          print $"(ansi green)Swapping packages from '(ansi cyan)($from_repo)(ansi green)':(ansi reset)"
+          $swap_packages
+            | each {
+              print $'- (ansi cyan)($in.old)(ansi green) -> (ansi cyan)($in.new)(ansi reset)'
+            }
+
+          for $pkg_pair in $swap_packages {
+            try {
+              (^dnf5
+                -y
+                swap
+                ...($replacement | install_args allow-erasing)
+                $pkg_pair.old
+                $pkg_pair.new)
+            } catch {
+              exit 1
+            }
+          }
+        }
+
+        if ($sync_packages | is-not-empty) {
+          print $"(ansi green)Replacing packages from '(ansi cyan)($from_repo)(ansi green)':(ansi reset)"
+          $sync_packages
+            | each {
+              print $'- (ansi cyan)($in)(ansi reset)'
+            }
+
+          try {
+            (^dnf5
+              -y
+              ($replacement | weak_arg)
+              distro-sync
+              ...($replacement | install_args)
+              --repo $from_repo
+              ...($sync_packages))
+          } catch {
+            exit 1
+          }
+        }
+      }
+    }
+  }
+}
+
+def main [config: string]: nothing -> nothing {
+  let config = $config
+    | from json
+    | default {} repos
+    | default {} group-remove
+    | default {} group-install
+    | default {} remove
+    | default {} install
+    | default [] optfix
+    | default [] replace
+  let has_dnf5 = ^rpm -q dnf5 | complete
+  let should_cleanup = $config.repos
+    | default false cleanup
+    | get cleanup
+
+  if $has_dnf5.exit_code != 0 {
+    return (error make {
+      msg: $"(ansi red)ERROR: Main dependency '(ansi cyan)dnf5(ansi red)' is not installed. Install '(ansi cyan)dnf5(ansi red)' before using this module to solve this error.(ansi reset)"
+      label: {
+        span: (metadata $has_dnf5).span
+        text: 'Checks for dnf5'
+      }
+    })
+  }
+
+  let cleanup_repos = repos $config.repos
+
+  try {
+    ^dnf5 makecache --refresh
+  } catch {
+    exit 1
+  }
+
+  run_optfix $config.optfix
+  group_remove $config.group-remove
+  group_install $config.group-install
+  remove_pkgs $config.remove
+  install_pkgs $config.install
+  replace_pkgs $config.replace
+
+  if $should_cleanup {
+    print $'(ansi green)Cleaning up added repos(ansi reset)'
+    remove_repos $cleanup_repos.files
+    disable_coprs $cleanup_repos.copr
+
+    match $config.repos.nonfree? {
+      $repo if $repo == $RPMFUSION => {
+        disable_rpmfusion
+      }
+      $repo if $repo == $NEGATIVO => {
+        disable_negativo
+      }
+      _ => {},
+    }
+    print $'(ansi green)Finished cleaning up repos(ansi reset)'
+  }
+}
diff --git a/modules/dnf/dnf.tsp b/modules/dnf/dnf.tsp
new file mode 100644
index 0000000..fb98271
--- /dev/null
+++ b/modules/dnf/dnf.tsp
@@ -0,0 +1,156 @@
+import "@typespec/json-schema";
+using TypeSpec.JsonSchema;
+
+@jsonSchema("/modules/dnf-latest.json")
+model DnfModuleLatest {
+  ...DnfModuleV1;
+}
+
+@jsonSchema("/modules/dnf-v1.json")
+model DnfModuleV1 {
+  /**
+   * The dnf module offers pseudo-declarative package and repository management using dnf.
+   * https://blue-build.org/reference/modules/dnf/
+   */
+  type: "dnf" | "dnf@v1" | "dnf@latest";
+
+  /** List of links to .repo files to download into /etc/yum.repos.d/. */
+  repos?: Repo;
+
+  /** List of folder names under /opt/ to enable for installing into. */
+  optfix?: Array<string>;
+
+  /** Configuration of RPM groups removal. */
+  `group-remove`?: GroupRemove;
+
+  /** Configuration of RPM groups install. */
+  `group-install`?: GroupInstall;
+
+  /** Configuration of RPM packages removal. */
+  remove?: Remove;
+
+  /** Configuration of RPM packages install. */
+  install?: Install;
+
+  /** List of configurations for replacing packages from another repo. */
+  replace?: Array<Replace>;
+}
+
+model Repo {
+  /** Cleans up the repos added in the same step after packages are installed. */
+  cleanup?: boolean = false;
+
+  /** List of paths or URLs to .repo files to import */
+  files?: Array<string> | RepoFiles;
+
+  /**
+   * List of COPR project repos to add.
+   * You can also specify 2 lists
+   * instead to 'enable' or 'disable' COPR repos.
+   */
+  copr?: Array<string> | RepoCopr;
+
+  /** List of links to key files to import for installing from custom repositories. */
+  keys?: Array<string>;
+
+  /**
+   * Enable one of the nonfree repos.
+   *
+   * This allows you to enable one of the nonfree repos.
+   * However, only one can be enabled at a time so if one
+   * is enabled, the other will be disabled if it is already enabled.
+   */
+  nonfree?: "negativo17" | "rpmfusion";
+}
+
+model RepoFiles {
+  /** List of repo files/URLs to add. */
+  add?: Array<string>;
+
+  /**
+   * List of repos to disable.
+   * This must be the ID of the repo
+   * as seen in `dnf5 repolist`.
+   */
+  disable?: Array<string>;
+}
+
+model RepoCopr {
+  /** List of COPR repos to enable */
+  enable?: Array<string>;
+
+  /** List of COPR repos to disable */
+  disable?: Array<string>;
+}
+
+model Install {
+  /** List of RPM packages to install. */
+  packages: Array<string | InstallRepo>;
+
+  ...InstallCommon;
+}
+
+model InstallRepo {
+  /** The repo to use when installing packages */
+  repo: string;
+
+  /** List of RPM packages to install. */
+  packages: Array<string>;
+
+  ...InstallCommon;
+}
+
+model Remove {
+  /** List of RPM packages to remove. */
+  packages: Array<string>;
+
+  /** Whether to remove unused dependencies during removal operation. */
+  `auto-remove`?: boolean = true;
+}
+
+model Replace {
+  /** URL to the source COPR repo for the new packages. */
+  `from-repo`: string;
+
+  /** List of packages to replace using packages from the defined repo. */
+  packages: Array<string | Swap>;
+
+  ...InstallCommon;
+}
+
+model Swap {
+  /** The package to be replaced. */
+  old: string;
+
+  /** The package to replace with. */
+  new: string;
+}
+
+model GroupInstall {
+  /** List of RPM groups to install. */
+  packages: Array<string>;
+
+  /** Include optional packages from group. */
+  `with-optional`?: boolean = false;
+
+  ...InstallCommon;
+}
+
+model GroupRemove {
+  /** List of RPM groups to remove. */
+  packages: Array<string>;
+}
+
+model InstallCommon {
+  /** Whether to install weak dependencies during the RPM group install or not. */
+  `install-weak-deps`?: boolean = true;
+
+  /** Whether to continue with the RPM group install if there are no packages available in the repository. */
+  `skip-unavailable`?: boolean = false;
+
+  /** Whether to continue with the RPM group install if there are broken packages. */
+  `skip-broken`?: boolean = false;
+
+  /** Whether to allow erasing (removal) of packages in case of dependency problems during the RPM group install. */
+  `allow-erasing`?: boolean = false;
+}
diff --git a/modules/dnf/module.yml b/modules/dnf/module.yml
new file mode 100644
index 0000000..295e5b1
--- /dev/null
+++ b/modules/dnf/module.yml
@@ -0,0 +1,47 @@
+name: dnf
+shortdesc: The dnf module offers pseudo-declarative package and repository management using dnf.
+example: |
+  type: dnf
+  repos:
+    cleanup: true # clean up added repos after module is done
+    files:
+      - https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo
+      - fury.repo
+    copr
+      - atim/starship
+      - trixieua/mutter-patched
+    keys:
+      - https://brave-browser-rpm-release.s3.brave.com/brave-core.asc
+    nonfree: rpmfusion
+  optfix: # performs symlinking for `/opt/` to allow certain packages to install
+    - Tabby # needed because tabby installs into `/opt/Tabby/`
+    - brave.com
+  install:
+    skip-unavailable: true # skip unavailable packages
+    packages:
+      - repo: brave-browser
+        packages:
+          - brave-browser
+      - starship
+      - https://github.com/Eugeny/tabby/releases/download/v1.0.209/tabby-1.0.209-linux-x64.rpm
+      - kubectl.rpm
+  remove:
+    packages:
+      - firefox
+      - firefox-langpacks
+  replace:
+    - from-repo: copr:copr.fedorainfracloud.org:trixieua:mutter-patched
+      skip-unavailable: true # skip unavailable packages
+      packages:
+        - mutter
+        - mutter-common
+        - gdm
+  group-install:
+    with-optional: true # install optional packages from group
+    packages:
+      - cosmic-desktop
+      - cosmic-desktop-apps
+      - window-managers
+  group-remove:
+    packages:
+      - development-tools
diff --git a/modules/dnf/optfix.sh b/modules/dnf/optfix.sh
new file mode 100644
index 0000000..07d12fb
--- /dev/null
+++ b/modules/dnf/optfix.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SOURCE_DIR="/usr/lib/opt"
+TARGET_DIR="/var/opt"
+
+# Ensure the target directory exists
+mkdir -p "$TARGET_DIR"
+
+# Loop through directories in the source directory
+for dir in "$SOURCE_DIR/"*/; do
+  if [ -d "$dir" ]; then
+    # Get the base name of the directory
+    dir_name=$(basename "$dir")
+    
+    # Check if the symlink already exists in the target directory
+    if [ -L "$TARGET_DIR/$dir_name" ]; then
+      echo "Symlink already exists for $dir_name, skipping."
+      continue
+    fi
+    
+    # Create the symlink
+    ln -s "$dir" "$TARGET_DIR/$dir_name"
+    echo "Created symlink for $dir_name"
+  fi
+done
diff --git a/modules/rpm-ostree/rpm-ostree.sh b/modules/rpm-ostree/rpm-ostree.sh
index f5f1564..80e5552 100644
--- a/modules/rpm-ostree/rpm-ostree.sh
+++ b/modules/rpm-ostree/rpm-ostree.sh
@@ -60,7 +60,8 @@ if [[ ${#OPTFIX[@]} -gt 0 ]]; then
     echo "Creating symlinks to fix packages that install to /opt"
     # Create symlink for /opt to /var/opt since it is not created in the image yet
     mkdir -p "/var/opt"
-    ln -fs "/var/opt"  "/opt"
+    ln -fs "/var/opt" "/opt"
+
 
     # Create symlinks for each directory specified in recipe.yml
     for OPTPKG in "${OPTFIX[@]}"; do