One "workaround" I came up with was to simply delete the side faces of the model entirely, and the issue goes away. This isn't ideal though and I'd much rather have a programmatic solution.
On the contrary, that IS ideal, because you are sending less data to the graphics card. You should only draw the sides of a tile when there is no adjacent tile on that side.
Anything I've read about z-fighting previously usually refers to 2 faces along the same plane having the issue
In your case, the z-fighting happens between the white side-face of a tile in the back, and the red front-face of the adjacent tile drawn in front of it. The reason is that the z-values (or "depth values") that are computed at the position of those white pixels for the two faces are equal. This is because the Z-values are stored as floating-point values in the Z-buffer, and the floating point rounding precision causes the two depth values to be the same.
The issue might also go away if you draw all of the front faces first, then all the sides, but you probably also have to enable enable Z-bias (also called "depth bias") for it to work. But as I said before, there is no point in drawing hidden faces, unless you also want to enable some kind of transparency later on, in which case you will have other issues to deal with.
You could also try increasing the Z-buffer precision if you can (from 16-bit to 32-bit floating points), but that will only decrease the amount of z-fighting - it will not fix it permanently.
And you should also make sure that the near and far planes of your viewing frustum are not too far apart. The Z-coordinates that span the range from the near plane to the far one are all compressed into the [0.0, 1.0] interval when placed in the Z-buffer. So the farther away you put the far plane from the near one, the more "depth" has to be represented in the same amount of floating-point precision in the Z-buffer, leading to more Z-fighting errors.