Docker on a VPS vs a Platform Build When the Agent Wrote the Dockerfile
Ivo Markham
September 30, 2026
The Dockerfile was fourteen lines long and looked fine at a glance. FROM node:latest, WORKDIR /app, COPY . ., RUN npm install, EXPOSE 3000, CMD ["npm", "run", "dev"], and a few lines in between. A client had asked a coding agent to “containerise the app so we can deploy it anywhere,” and this was the result. They sent it to me with a simple question: should they run it on the small VPS I already manage for them, or hand it to one of the platforms that builds and runs Dockerfiles for you?
I tried both. The same fourteen lines behaved completely differently in the two places, and the reasons had very little to do with Docker and a lot to do with what the Dockerfile quietly assumed.
On the VPS, it just worked, which was the problem
I cloned the repo onto the client’s VPS, wrote a short Compose file, ran docker compose up -d --build, pointed Caddy at port 3000, and the app loaded. Total time: about ten minutes.
That smoothness hid four things I only found when I looked more carefully.
The image contained the .env file. There was no .dockerignore. COPY . . copied everything in the directory into the image, including the .env file with the production database password and a payment API key. On the VPS, that image never left the machine, so the exposure was limited. But it was baked into a layer, and if anyone ever pushed that image to a registry, the secrets would go with it.
It was running the development server. npm run dev starts the framework’s dev mode, with hot reloading, verbose errors shown to visitors, unminified bundles, and much higher memory use. It worked. It was also slow, leaked stack traces on errors, and used about three times the RAM of a production build.
It ran as root. No USER line, so the Node process ran as root inside the container. Not catastrophic on its own, but it removes a layer of protection if anything in the dependency tree is compromised.
The base image would change under us. node:latest meant the next rebuild could pull a new major version of Node. The app would build on a different runtime than it was tested on, without anyone deciding that.
On the VPS, none of these stopped the app from running. That is the thing about a machine you control: it is forgiving. Port 3000 is open because you opened it. Files persist because you mounted a volume. The dev server keeps running because nothing checks whether it is a dev server. The Dockerfile’s bad habits all survive, silently.

On the platform, it failed three times before it ran
Then I connected the same repository to a container platform that builds from a Dockerfile. It was much less forgiving.
Failure one: the health check. The platform started the container and tried to reach it on the port it had assigned, which it passed in via a PORT environment variable. The app ignored PORT and listened on 3000, a number the agent had hard-coded. The health check failed, the platform killed the container, and the deploy was marked as failed.
Failure two: listening on the wrong address. After I changed the app to read PORT, it still failed. The dev server bound to localhost, which inside a container means only the container itself can reach it. On the VPS, this had not mattered because of how the Compose networking and Caddy happened to be set up. On the platform, the health check came from outside the container and could not connect. The fix was to bind to 0.0.0.0.
Failure three: out of memory. The platform’s smallest instance had 512 MB. The dev server, with its file watcher and in-memory bundling, blew through that during startup. I switched the CMD to a production build and start, and it settled at about 150 MB.
After those three fixes, the app ran. And I realised that every failure the platform had thrown at me was a real problem the VPS had been hiding. The platform was not being difficult. It was enforcing the contract that a container is supposed to follow: read your port from the environment, listen on all interfaces, be a production process, fit in the memory you asked for.
What the agent’s Dockerfile should have looked like
I asked the agent to rewrite the Dockerfile with a short list of requirements, and the result was excellent. The agent knows how to write a good Dockerfile. It just defaults to the shortest one that works on a laptop unless you tell it what “works” means.
The requirements I gave it, which I now use on every project:
- Pin the base image to a specific major version and a slim variant, such as
node:22-slim, neverlatest. - Use a multi-stage build: install and build in one stage, copy only the build output and production dependencies into the final stage.
- Include a
.dockerignorethat excludes.env*,node_modules,.git, test files, and local data. - Run as a non-root user.
- Start the production server, not the dev server.
- Read the port from
PORT, with a default, and bind to0.0.0.0. - Never bake secrets into the image; read them from the environment at runtime.
- Do not write to the container filesystem for anything that must persist.
The new image was about 180 MB instead of 1.4 GB, started in two seconds instead of fifteen, and ran identically on the VPS and the platform.
The agent also added a HEALTHCHECK line using curl, which is not installed in the slim image. The check failed permanently, and the container showed as unhealthy even though the app was fine.

So which should run it?
Once the Dockerfile was sound, the choice between the VPS and the platform became a normal operations decision, not a debugging exercise.
The VPS makes sense when:
- You already run one and have a routine for patching, backups, and monitoring.
- The app needs things next to it: a database container, a worker, Redis, a persistent volume for uploads, all in one Compose file.
- Costs need to be flat and predictable. A VPS costs the same whether the app is busy or idle.
- You want to see exactly what is running with
docker psanddocker logs, without a dashboard in between.
The platform makes sense when:
- Nobody wants to own an operating system. The platform patches the host, restarts crashed containers, and handles TLS.
- The app is stateless, or its state lives in managed services.
- You want rolling deploys, instant rollback to a previous image, and multiple regions without building any of it.
- Traffic is spiky, and you would rather pay for usage than for a box sized for the peak.
For this client, the app is one Node service and a Postgres database, with small, steady traffic, and I already manage their VPS. It stayed on the VPS. But I kept the platform deployment configured and working, because the fact that it runs there is now my test that the Dockerfile is honest.
The platform as a lie detector
That last point is the one I most want people to take away. If an agent wrote your Dockerfile, deploying it once to a strict container platform, even one you have no plans to use, is a cheap way to find out what it assumes. Platforms enforce the rules that a hand-managed VPS lets you forget. If it runs there, it will almost certainly run on your VPS. The reverse is not true.
I have since added a CI step that builds the image and runs it with a random PORT, no .env file, a 512 MB memory limit, and a read-only root filesystem, then checks that the app responds. It catches most of what the platform caught, without needing an account anywhere. It has failed on two agent pull requests since, both times because a new feature wrote a cache file to the working directory.
What I tell clients now
“Containerise it so we can deploy it anywhere” is a good instruction with a hidden gap. Anywhere is a lot of places, and a Dockerfile that works on one machine is not the same as a Dockerfile that follows the rules every container host expects.
So now, when a client asks an agent for a Dockerfile, I suggest they paste in the eight requirements above, and add one more sentence: “It must run on a platform that assigns the port, limits memory to 512 MB, and provides no files except the image.” That sentence alone gets you most of the way to an image you can put on a VPS, a platform, or somewhere neither of us has thought of yet.
The client’s .env file, by the way, was never pushed to a registry. I checked. We rotated the database password and the payment key anyway, because an image that has contained a secret should be treated as having leaked it, even if you are fairly sure it did not go anywhere.