Bar chart of idle memory per hello-world container: nginx static 6.1 MiB, PHP 8.3 12.8 MiB, .NET 8 27.2 MiB, Python 3.12 31.6 MiB, Java 21 32.3 MiB, Spring Boot 3.5 with CDS 130.5 MiB, Spring Boot 3.5 163.8 MiB

Memory is what small app plans are sized by, and it is the first limit a new SaaS app hits. Before you pick a 128 MB, 256 MB or 512 MB container for your app, it helps to know what the runtime itself takes before your code does anything. So we measured it: the same one-route JSON app in PHP, Python, plain Java, Spring Boot, .NET and a static nginx site, each in its own container, run 72 times.

Short version: a static site needs about 6 MiB, PHP about 13 MiB, and .NET, Python and plain Java land close together at 27 to 32 MiB. Spring Boot is in a different class at 164 MiB, but most of that is heap the JVM takes because it can, and it shrinks to 114 MiB as soon as the container has a limit.

How we measured

Each app answers GET / with {"hello":"world"} and nothing else, built on the official base image for its language and run as a non-root user. We started each container, waited for the first 200, sent 50 requests, let it idle for 3 seconds and read docker stats. Every runtime ran 3 times with no memory limit, 3 times with --memory 256m and 3 times with --memory 128m (swap off). Memory below is the median, in MiB as Docker reports it; PIDs counts processes and threads.

text
machine     Docker Desktop, Docker Engine 29.6.1, WSL2 kernel 5.15.167
            6 CPUs and 11.69 GiB of memory visible to containers
app         one route, GET / -> {"hello":"world"}, port 8080, non-root user
images      official bases: nginx:1.29-alpine, php:8.3-cli, python:3.12-slim,
            eclipse-temurin:21-jre, mcr.microsoft.com/dotnet/aspnet:8.0
per run     docker run -d (no limit, --memory 256m or --memory 128m, swap off)
            poll GET / every 10 ms until the first 200
            docker stats, then 50 requests, 3 s idle, docker stats again
runs        3 per runtime and memory setting, 72 runs, medians reported

The stacks are the ones most people would start with: nginx serving one HTML file, PHP 8.3's built-in server with 4 workers, FastAPI on uvicorn (one process), the JDK's own com.sun.net.httpserver on Java 21, Spring Boot 3.5.6 with spring-boot-starter-web (Tomcat), and an ASP.NET Core 8 minimal API. Spring Boot ran twice: as the plain fat jar, and extracted with a class data sharing (CDS) archive.

The results

RuntimeStackNo limit256 MB limit128 MB limitPIDs
nginx 1.29static file6.1 MiB6.1 MiB6.1 MiB7
PHP 8.3built-in server, 4 workers12.8 MiB12.8 MiB12.8 MiB5
.NET 8ASP.NET Core minimal API27.2 MiB26.9 MiB27.0 MiB23
Python 3.12FastAPI 0.120 + uvicorn 0.3831.6 MiB31.6 MiB31.6 MiB2
Java 21JDK HttpServer32.3 MiB27.1 MiB28.1 MiB20
Java 21Spring Boot 3.5 + CDS130.5 MiB98.9 MiB100.5 MiB38
Java 21Spring Boot 3.5163.8 MiB113.5 MiB113.7 MiB40
docker stats output with all eight hello-world containers idle: dotnetr2r 27.76MiB, dotnet 25.8MiB, springcds 122.7MiB, spring 157.7MiB, javaplain 32.74MiB, php 12.84MiB, python 31.6MiB, static 6.07MiB
All eight containers running side by side after 50 requests each. The numbers move by a few MiB from run to run, which is why the table uses medians.

Every app served every request in all 72 runs, including Spring Boot at 128 MB. None was killed for running out of memory.

Why Java uses less when you give it less

The interpreted runtimes don't care about the limit: PHP, Python and nginx used the same memory with or without one. The JVM does care, because it sizes its heap from the memory it can see. With no limit our containers could see 11.69 GiB, so the JVM allowed itself a 2.92 GiB heap and Spring Boot spread out to 164 MiB. Under a limit it picks a much smaller maximum heap and the same app runs in 114 MiB:

text
$ docker run --rm spring-hello java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
   size_t MaxHeapSize = 3137339392    (2.92 GiB, no limit, 11.69 GiB visible)
$ docker run --rm -m 256m spring-hello java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
   size_t MaxHeapSize = 132120576     (126 MiB)
$ docker run --rm -m 128m spring-hello java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
   size_t MaxHeapSize = 67108864      (64 MiB)

So a Spring Boot number measured on a laptop with no limit says little about what it needs in production. Measure it with the limit you plan to use, or set the heap yourself with -XX:MaxRAMPercentage or -Xmx so it doesn't depend on the machine.

Workers multiply the Python number

The Python figure is one uvicorn process. Real deployments often run several workers for concurrency, and each one is a full copy of the interpreter and your imports. We ran the same image with --workers:

uvicorn workersMemory after 50 requestsPIDs
1 (no supervisor)31.6 MiB2
287.0 MiB8
4147.6 MiB13

That is roughly 30 MiB per extra worker plus a supervisor process. A FastAPI app with 4 workers already needs more memory than Spring Boot under a limit, before your own code and libraries are loaded. With async code, one worker is often enough for a small app; add workers when you measure that you need them.

Image size is a different story

Image size on disk per hello-world image: nginx static 92.6 MB (26 MB pull), Python 3.12 212 MB (51.7 MB), .NET 8 321 MB (90.4 MB), Java 21 456 MB (115 MB), Spring Boot 3.5 496 MB (134 MB), PHP 8.3 743 MB (181 MB)
Image sizes from docker image ls: unpacked size on disk, with the compressed download below each name.

The order flips for disk. PHP had the smallest memory use but the largest image: the official php:8.3-cli image is 743 MB unpacked (181 MB to download), against 212 MB for python:3.12-slim. Image size affects how fast a new server can pull and start your app and what you pay for registry storage, but it doesn't affect memory at run time. The CDS archive costs Spring Boot 80 MB more disk (576 MB) for its smaller memory and faster start.

What to take from it

For a small SaaS app, the runtime's own share of memory is: under 15 MiB for static sites and PHP, around 30 MiB for one .NET, Python or plain Java process, and 100 to 165 MiB for Spring Boot depending on the limit. Your code, libraries, caches and open database connections come on top. A rough plan: 128 MB is comfortable for PHP, single-worker Python and .NET minimal APIs; Spring Boot runs at 128 MB but has little room left for your app, so 256 MB or more is the safer start; and every extra Python worker should be counted at about 30 MiB of its own.

If you want to check your own app the same way, run it with the limit you plan to buy, send it some real requests and read docker stats --no-stream. The idle number after real traffic is the one to size by, not the number right after start.

The systemi Deploy Python docs page: detected by requirements.txt or pyproject.toml, Python 3.12 on Debian slim, start with uvicorn main:app when fastapi is in the requirements
How systemi runs a Python app: the same python:3.12-slim base and uvicorn start used in this test (systemi.in/docs/python).

systemi runs PHP, Python, Java, .NET and static apps, each in its own container with its own PostgreSQL database and a free subdomain.

See how deploys work