In addition to nix and guix, which have been mentioned, you can also use straight.el[0]. It's a functional package manager for emacs, so you'd use it instead of package.el. It checks out the git repo for each package and maps package name->commit hash in a nice little file, which you can then commit to git. Then, on a new machine, it will check out that specific rev for all your packages.
It even has nice use-package integration, so if you're already using that you likely won't have to change all your config.
Plus 1 to this. I've adopted straight.el a little while ago and I've been especially loving how easy it makes hacking on my installed packages. Now that they're version controlled, I can easily try things and back them out if they don't work. And more importantly I can remember the tweaks I've made, because there's a readily-available diff. With regular package.el it was nearly impossible to keep track of little changes I had made and preserve them across upgrades or transfer them to another machine.
It even has nice use-package integration, so if you're already using that you likely won't have to change all your config.
[0]https://github.com/raxod502/straight.el