In theory, we use dev containers, because, we don't have the same environment locally. If one would develop NodeJs, they shouln't have to install node on their main machine that they use as a daily driver. They only need the image, that has these tools. And the best way to define images is using docker.
Dockerfile is for building an image. Containerd is for starting a process inside one of these images. An isolated process is called as "container". (you can use linux utilities for this, docker is optional) However, docker makes it easy to mount the image and make the image as root of the container.
Imagine that you have a "node:latest" image. You want to use npm for like checking the version. You run this:
docker-compose run node npm --version
It's clear that you don't need npm install to do this.
So reason 1: unnecessary steps required for running a simple command.
Unfortunately docker is very slow. Docker chaches every line in Dockerfile as a new layer. This results in huge images. It's because if you change one line, it will start from the unchanged lines and builds the rest again. Saves the image again. And the whole process is time consuming. On the other hand containers are very fast, as they are just simple processes. (it's as really fast as linux distros use this to isolate apps from each other.) And if you mount a volume, that will be your native filesystem performance. That's what developers want I think.
So reason 2: slow build time and the need for rebuilding
If you want only one command and the project is up and running for a newly hired colleuge. You have to use shell scripts. Makefile is a popular option for combining simple shell scripts into one file. You need usually manny things to do: install dependencies, init db, run migrations, run seeds, generate new secrets, generate language files, etc... If you include these to every project startup that would be very time consuming, and it could for example remove the users in this example at every run. So I think it's useful to separate the install and the run.
"install:" would do every stuff like: cp .env.sample .env docker-compose run backend npm run migrate && npm run swagger
"run:" is just a "docker-compose up -d" that would just init the containers, so you can continue from where you were yesterday instantly. Or if you stopped the docker daemon to have better fps in CS:GO, you can start it back instantly, without rebuilding it.
If you add a dependency or you checkout a branch. It's useful to have an "update:" script. That reinstalls the dependencies, migrates, without wiping the db. and so on...
You don't want to install more than once, so if you place install script into the Dockerfile, you cannot just docker-compose up --build everytime.
I think it should be up to you to decide whether you rebuild the image with or without the npm install.
So reason 3: you have to have a Makefile