Cloud Panel

Cloud server comparison 2026: Hetzner vs Vultr vs DigitalOcean vs ServersCamp

We ran the same apps on four cloud servers and raised the load until each one slowed down. November 2026.

We decided to measure ourselves against the big names. Hetzner, Vultr and DigitalOcean are the clouds everyone has heard of, so we took about EUR 100 a month and looked at what each of them gives you for that money, and what we give.

The plan was bigger. We wanted every well-known European option in one table. Linode did not open an account for us :( Contabo has been checking our account for more than a day and is still thinking. OVHcloud simply has no VPS in stock anywhere in Europe right now. So four of us stayed in the ring.

From each provider we took the plan it sells for production work, the one with dedicated CPU, at around the same money. Then we did what a customer does with a server. We ran applications on it. The backend of a small social network, a hotel booking service and a web shop, the same code and the same data on every server. Each one got more traffic step by step until the answers started to slow down, and we wrote down where that happened.

After the applications we also ran the database benchmarks and disk tests people usually ask for. They are further down the page.

The contenderstop

First a word about who is in the ring. Hetzner has been around since 1997 and runs its own data centers in Germany and Finland. DigitalOcean started in 2011 and made the cheap developer server popular. Vultr came in 2014 and has more locations around the world than most. We registered the company in October 2024, so on age we lose to everyone here by a long way. It does not worry us much, but it is fair to say it up front.

All four are full clouds, not just servers. You can drive everything from an API, a CLI or Terraform, and you pay for the time a server lives, not for a whole month in advance.

 ServersCampHetznerVultrDigitalOcean
Founded2024199720142011
Billinghourly, prepaid in EURhourly, capped at the monthly pricehourly, capped at the monthly priceper second, capped at the monthly price
AutomationAPI, CLI, TerraformAPI, CLI, TerraformAPI, CLI, TerraformAPI, CLI, Terraform
Backupsper GB of compressed size, encrypted copies at another provider in another countryautomatic, 20% of the server priceautomatic, paid add-onweekly 20% or daily 30% of the server price
Snapshots yes, on a schedule yes yes yes
Managed databases yes, eight enginesno yes yes
Managed Kubernetes yesno yes yes
Object storage yes yes yes yes
Server disknetwork storage, replicated across serverslocal to the hostlocal to the hostlocal to the host
Disk sizeany size, paid apart from the server, grows while the server runsfixed by the plan, more space on a separate volumefixed by the plan, more space on a separate volumefixed by the plan, more space on a separate volume

The last row matters for everything below. At Hetzner, Vultr and DigitalOcean the server disk sits in the same machine as the server. When that machine dies, the disk goes with it until someone brings it back. Our disks live on separate storage and every block is kept on several servers, so a failed host does not take the data with it. Because the disk is not part of the server, you pick its size on its own and make it bigger on the fly, without changing the plan or stopping anything. A disk like that has to send each write over the network before it confirms it, and you will see what that costs in the disk tests.

For this test we took one plan from each.

 ServersCampHetznerVultrDigitalOcean
Planhf-16g-dedicatedCCX23voc-g-4c-16gb-80sgd-4vcpu-16gb-intel
vCPU / RAM6 / 16 GB4 / 16 GB4 / 16 GB4 / 16 GB
CPU we gotAMD EPYC 4565PAMD EPYC MilanAMD EPYC RomeIntel Xeon Platinum 8358
Disk100 GB Enterprise160 GB local80 GB local120 GB local
LocationRomaniaNurembergFrankfurtFrankfurt (FRA1)
Price a monthEUR 93.50EUR 86.49USD 120 (EUR 107.41)USD 158 (EUR 141.42)

Each server is tested the way it is sold. The plans differ in vCPU, disk and price, and we did not even them out, because a customer pays for the whole server and gets the whole server. Ours has 6 vCPU against 4 because our High Frequency line is built for work that needs CPU and gives more cores for the same memory.

Dedicated CPU also means slightly different things. Usually it is a vCPU tied to a core of the host. We do not tie vCPU to cores. Our dedicated servers get priority instead: when a host gets busy, the shared servers on it slow down first and the dedicated ones keep their full speed. Shared vs dedicated

Nobody picks the processor when ordering. The CPU row is what each server reported about itself on the day of the test. Vultr and DigitalOcean bill in US dollars, we converted at the ECB rate of 30 October 2026, 1 EUR = 1.1172 USD. All prices are without VAT.

AWS, Google Cloud and Azure are not here. For about EUR 100 a month they give you a smaller server than any in this table, and the disk and traffic are billed on top. That is a different purchase.

Methodtop

Every provider got two servers in the same location: the one under test and a second, smaller one that sends the traffic. The traffic went between them over the provider's own network, the way an application talks to its database or a load balancer to its servers. Both ran a clean Ubuntu 26.04 with the same packages and versions.

The load grows in steps. Each step holds a fixed number of requests per second for five minutes, then the next one adds more. The sender keeps that pace even when the server falls behind, so a slow answer is counted as slow and not quietly skipped. For every step we kept the 95th percentile of response time: 95 requests out of 100 were faster than that. A server holds a step while this number stays under 50 ms for the social network and the booking service, and under one second for the web shop, which is slower by nature.

Every application started from a clean install with the same data on all four servers. While the tests ran we recorded CPU, memory, disk and steal time every second on both machines. The sending servers never came close to their limit, and steal on the servers under test stayed near zero.

WorkloadWhat we used
Social network, hotel bookingDeathStarBench at commit 6ecb097, load from its own wrk2
Web shopSaleor 3.23 in production mode, load from k6 with our shopper script
DatabasesPostgreSQL 18.6 from the Ubuntu archive, HammerDB 6.1.1, pgbench
Disk and CPUfio, Geekbench 6 from YABS
Networkiperf3 from YABS to public test servers in Europe, the US and Asia

Each test ran once, on 1 November 2026. The commands are in the last section.

Social networktop

The first application is a small social network from DeathStarBench, a set of test applications from Cornell University. It is about 30 small services written in C++ that call each other for every request, with MongoDB, Redis and Memcached behind them. Like on any social network, people mostly read: 60% of requests open the home feed, 30% open someone's page and 10% publish a new post. The users and who follows whom come from a public dataset of 962 Facebook users.

Social network: p95 response time by load (lower is better) 2 ms 5 ms 10 ms 20 ms 50 ms 100 ms 200 ms 500 1000 1500 2000 2500 requests per second 50 ms limit 43.7 s 19.4 s 1.2 s 58 s ServersCamp Hetzner Vultr DigitalOcean
p95 response time at each 5 minute step, on a log scale. A point on the top edge went past 200 ms, the number above it shows how far.

Hetzner ran out first. At 600 requests per second its p95 jumped from 30 to 134 ms. DigitalOcean held 600 and fell apart at 700. Vultr held 800. Our server held 1 500 and crossed the limit somewhere between 1 500 and 2 000. Past their limit none of the servers returned errors, the requests simply waited in line until every answer took seconds.

RPSServersCampHetznerVultrDigitalOcean
2003.1 ms6.4 ms5.1 ms7.6 ms
4003.6 ms13.7 ms6.5 ms12.2 ms
5003.9 ms29.5 ms7.5 ms18.1 ms
6004.4 ms134 ms9.6 ms40.9 ms
7004.9 ms43.7 s13.4 ms19.4 s
8005.6 ms-26.4 ms-
9006.2 ms-97 ms-
1 0006.9 ms-1.16 s-
1 2008.7 ms---
1 50011.8 ms---
2 000103 ms---
2 50058 s---

Red: p95 over the 50 ms limit.

Hotel bookingtop

The second application is a hotel booking service from the same DeathStarBench set. This one is written in Go, its services talk to each other over gRPC and keep their data in MongoDB and Memcached. Most requests look for hotels near a point on the map or ask for recommendations, one in a hundred logs in or books a room. We stepped this one in bigger jumps, because the first run already showed a wide gap.

Hotel booking: p95 response time by load (lower is better) 2 ms 5 ms 10 ms 20 ms 50 ms 100 ms 200 ms 500 ms 500 1 000 1 500 2 000 3 000 4 000 requests per second 50 ms limit 21 s 67 s 7.2 s 43 s ServersCamp Hetzner Vultr DigitalOcean
p95 response time at each 5 minute step, on a log scale. A point on the top edge went past 500 ms, the number above it shows how far.

At 500 requests per second all four answered quickly. At 1 000 Vultr was just over the limit with 52 ms, Hetzner had reached a third of a second and DigitalOcean could not keep up at all. Our server held 2 000 and broke at 3 000. With steps this coarse the limit of the others lies somewhere between 500 and 1 000, and we did not narrow it down.

RPSServersCampHetznerVultrDigitalOcean
5004.5 ms13.7 ms10.1 ms22.0 ms
1 0006.4 ms335 ms52 ms21 s
1 5008.9 ms67 s7.2 s-
2 00013.4 ms---
3 000130 ms---
4 00043 s---

Red: p95 over the 50 ms limit.

Web shoptop

The third application is a web shop on Saleor, an open source shop platform written in Python with Django, PostgreSQL and Valkey, plus background workers that process orders. It ran in production mode with one web worker per vCPU. Our script plays the shoppers: 40% browse the catalog, 25% open a product, 20% search and 15% go all the way through checkout and pay through Saleor's test payment gateway. A checkout alone is four calls to the shop, so the numbers here are small and the limit is one second.

Web shop: p95 response time by load (lower is better) 100 ms 200 ms 500 ms 1 s 2 s 5 s 10 s 20 s 2 4 6 8 10 15 20 30 shopper actions per second 1 s limit ServersCamp Hetzner Vultr DigitalOcean
p95 response time at each 5 minute step, on a log scale.

At two actions per second the others answered in about half a second and ours in a fifth of a second. Hetzner and DigitalOcean crossed one second at 6 actions per second, Vultr at 10. Ours stayed at half a second at 20 and broke at 30, when the background workers busy with orders took all of its CPU. Python handles each request on a single core, so here the speed of one core matters more than anywhere else in this test. About one checkout in 70 at 6 actions per second came back without delivery options on every server, ours too, and we counted those as failed.

Actions/sServersCampHetznerVultrDigitalOcean
2190 / 200 ms504 / 619 ms437 / 482 ms487 / 642 ms
4205 / 279 ms659 / 983 ms480 / 750 ms639 / 918 ms
6207 / 294 ms1 060 / 1 817 ms646 / 1 177 ms1 196 / 2 195 ms
8227 / 356 ms6.6 s981 / 1 827 ms14.7 s
10251 / 399 ms-1 268 / 2 175 ms-
15347 / 626 ms-11.3 s-
20505 / 927 ms---
303 573 / 7 922 ms---

Red: p95 over the 1 s limit.

Database: HammerDB TPROC-Ctop

Now the databases. HammerDB runs TPROC-C, a test built like an order system: new orders, payments, deliveries, stock checks. It counts new orders per minute (NOPM). PostgreSQL 18.6 from the Ubuntu archive had the same settings on every server, and every commit was written to disk before it was confirmed. The database had 50 warehouses, about 5 GB, and we raised the number of virtual users from 1 to 64.

HammerDB TPROC-C: new orders per minute (higher is better) 0 50k 100k 150k 200k 250k 300k 350k 1 2 4 8 16 32 64 virtual users ServersCamp Hetzner Vultr DigitalOcean
New orders per minute at each number of virtual users, 2 minutes measured after 1 minute of warm-up.

With one user every server works one order at a time, so the speed of a query and of a commit decides. We were ahead from the first step, 46 thousand against 35 to 36 thousand at Hetzner and Vultr. From 8 users the others barely grew: Hetzner levelled off near 130 thousand, DigitalOcean near 125 thousand and Vultr near 157 thousand. Ours kept going up to 314 thousand at 32 users, twice the best of the others, although a single write on our disk is slower, as the disk tests below show.

Virtual usersServersCampHetznerVultrDigitalOcean
146 39136 41835 26120 978
287 98271 35971 05045 292
4137 701117 850127 92175 521
8209 235122 356140 965100 234
16266 316126 430151 230121 408
32314 055129 596157 002125 479
64293 191123 206157 395114 051

Database: pgbench TPC-Btop

pgbench comes with PostgreSQL. Its default test, TPC-B, is almost pure writing: every transaction changes three rows, adds one and commits. That makes it the hardest test here for a disk that keeps every write on several servers before it confirms it. We ran it on two database sizes, 7.5 GB that fits in memory and 22 GB that does not, with pgbench on the second server and 60 seconds per step.

pgbench TPC-B, 7.5 GB: transactions per second (higher is better) 0 2k 4k 6k 8k 10k 1 4 8 16 32 64 clients ServersCamp Hetzner Vultr DigitalOcean
Transactions per second at each number of clients, database of 7.5 GB.
pgbench TPC-B, 22 GB: transactions per second (higher is better) 0 2k 4k 6k 8k 10k 1 4 8 16 32 64 clients ServersCamp Hetzner Vultr DigitalOcean
Transactions per second at each number of clients, database of 22 GB.

With one to four clients Hetzner was the fastest. At one client it did 927 transactions per second against our 672, and 791 against 555 on the larger database. One client means one commit at a time, and a local disk confirms a single write sooner. From 8 clients the commits reach the disk in groups and the CPU starts to decide. The others levelled off at 4 to 5 thousand, we went on to 8 696 on the small database and 7 805 on the large one.

ClientsServersCampHetznerVultrDigitalOcean
Scale 500
1672927556202
42 4963 0662 186752
84 2944 2513 4331 534
166 7864 8674 4482 951
328 3914 6614 5384 006
648 6964 3744 5104 093
Scale 1500
1555791511216
42 0402 6821 893763
83 8444 0093 2081 332
165 8714 2434 0352 800
327 3514 0604 2323 631
647 8053 6834 0503 531

Disktop

The disk tests show the other side of it. fio ran on the root disk of each server, 30 seconds per test, with the page cache out of the way.

Disk, one operation at a time: microseconds (lower is better) 0 µs 50 µs 100 µs 150 µs 200 µs 250 µs 300 µs 350 µs 159 92 120 109 read 146 42 43 39 write 179 111 108 90 write + fdatasync 314 141 144 109 write + fsync ServersCamp Hetzner Vultr DigitalOcean
One 4 KB operation at queue depth 1, median time. For the two synced writes the time is one second divided by the IOPS fio measured.
Disk, many operations at once: IOPS (higher is better) 0 50k 100k 150k 200k 250k 185k 142k 151k 161k random read 200k 134k 149k 139k random write ServersCamp Hetzner Vultr DigitalOcean
Random 4 KB operations, queue depth 32 with 4 jobs.
 ServersCampHetznerVultrDigitalOcean
Random read 4k, QD1, median159 us92 us120 us109 us
Random write 4k, QD1, median146 us42 us43 us39 us
4k write + fsync, IOPS3 1827 1166 9659 214
4k write + fdatasync, IOPS5 5939 0359 29811 162
Random read 4k, QD32 x 4 jobs, IOPS185 292141 722150 920160 732
Random write 4k, QD32 x 4 jobs, IOPS200 184134 367149 191139 053

A single 4 KB write takes 146 µs on our disk and about 40 µs on theirs, and with an fsync after every write we do about 3 thousand a second against 7 to 9 thousand. That is what it costs to keep every write on several servers. With many writes in flight at once the picture turns around: 185 to 200 thousand IOPS on ours against 134 to 161 thousand on theirs. In the three applications the CPU ran out long before the disk did.

YABStop

 ServersCampHetznerVultrDigitalOcean
Geekbench 6 single / multi3 212 / 11 8881 570 / 3 6561 603 / 5 1981 395 / 2 989
fio 4k mixed, IOPS194.1k131.2k136.7k133.7k

YABS is the script many people run on a new server to see what they got. One core of our EPYC 4565P scored about twice as high as the others in Geekbench 6, and with all cores busy the gap grows, because we have 6 vCPU against 4. Full results: ServersCamp, Hetzner, Vultr, DigitalOcean.

Networktop

YABS also measures the network with iperf3: it sends traffic to public test servers around the world and receives it back. Everyone who runs YABS uses the same servers, so a busy one gives a low number or none at all, and one run is a rough picture. All numbers are over IPv4, because the Vultr and DigitalOcean servers had no IPv6 address.

Network, from the server out: Gbit/s (higher is better) 0G 3G 6G 9G 12G 15G 5.7 0.06 14.1 0.10 Amsterdam 3.5 0.36 6.4 0.06 London 3.4 1.8 6.0 2.2 New York 1.9 busy busy 1.5 Tashkent 2.1 0.40 3.6 1.1 Singapore 0.88 0.79 1.2 1.1 Los Angeles ServersCamp Hetzner Vultr DigitalOcean
iperf3 upload from each server to the test point, one run. "busy" means the test point refused the run.
Network, into the server: Gbit/s (higher is better) 0G 3G 6G 9G 12G 8.3 6.3 11.2 9.8 Amsterdam 2.6 3.0 2.8 4.8 London 2.9 8.5 3.5 6.9 New York 0.38 5.3 3.8 7.9 Tashkent 1.8 busy 3.4 5.3 Singapore 0.73 1.4 1.3 1.6 Los Angeles ServersCamp Hetzner Vultr DigitalOcean
iperf3 download from the test point to each server, one run.

Out of the server ours was second after Vultr at almost every point, without drops. Hetzner and DigitalOcean sent only 60 to 360 Mbit/s to London and Amsterdam in this run, while receiving several gigabits from the same points. One run cannot tell a busy route from a limit, and over IPv6 Hetzner sent 7.5 Gbit/s to London.

Into the server ours was the slowest at most points: 2 to 3 Gbit/s from London and New York and under 1 Gbit/s from Tashkent and Los Angeles, where the others took 3 to 9 Gbit/s. Our data center is in Romania and theirs are in Germany, and the distance shows in the latency too.

Ping, msServersCampHetznerVultrDigitalOcean
Amsterdam35.79.17.48.3
London41.717.512.917.6
New York12199.785.486.2
Tashkent12191.281.497.9
Singapore175163154155
Los Angeles187164152149
Same location0.420.220.220.49

The last row is the ping between the two servers of each provider, taken while the load tests ran. It is part of every request in the database tests, where the client sits on the second server.

Per eurotop

The same results divided by the monthly price: how much work one euro a month buys in each test. Every test has its own unit, so each one gets its own small chart.

Work per euro a month (higher is better) Social network RPS per EUR 16.0 5.8 7.4 4.2 Hotel booking RPS per EUR 21.4 5.8 4.7 3.5 Web shop actions/s per EUR 0.21 0.05 0.07 0.03 HammerDB NOPM per EUR 3359 1498 1465 887 pgbench 22 GB TPS per EUR 83 49 39 25 ServersCamp Hetzner Vultr DigitalOcean
Limit or peak of each test divided by the monthly price from the table of contenders. USD at the ECB rate of 30 October 2026.
Per EUR a monthServersCampHetznerVultrDigitalOcean
Social network, RPS16.05.87.44.2
Hotel booking, RPS21.45.84.73.5
Web shop, actions/s0.210.050.070.03
HammerDB, NOPM3 3591 4981 465887
pgbench scale 1500, TPS83493925

Summarytop

Here is everything on one screen. Each number says how many times more our server handled than the other one at the same test. Below 1× means the other server was faster.

ServersCamp handledvs Hetznervs Vultrvs DigitalOcean
Social network3.0×1.9×2.5×
Hotel booking4.0×4.0×4.0×
Web shop5.0×2.5×5.0×
HammerDB, peak2.4×2.0×2.5×
pgbench 22 GB, peak1.8×1.8×2.2×
pgbench 22 GB, 1 client0.7×1.1×2.6×
Disk, single write0.3×0.3×0.3×
For about the same money our server carried two to five times the load of the others in the three applications and about twice the database work once many clients were busy. The others were faster where the disk does one thing at a time: a single write takes them a third of the time, and with one to four pgbench clients Hetzner stayed ahead. Our disk keeps every write on several servers, theirs keep it in one machine.

Conclusiontop

We are not writing a conclusion.

Reproduce ittop

Two servers per provider in the same location, a clean Ubuntu 26.04 on both. SERVER below is the address of the server under test as the second server sees it. Every load step ran for 5 minutes with 30 seconds of quiet in between, and we stopped once p99 went past 2 seconds.

Social network and hotel booking

Server under test

bash
apt-get install -y docker.io docker-compose-v2 git python3-aiohttp
git clone https://github.com/delimitrou/DeathStarBench.git
cd DeathStarBench && git checkout 6ecb09706140f8730b5385c08f1386c654c3c526

# social network
cd socialNetwork && docker compose up -d
python3 scripts/init_social_graph.py --graph=socfb-Reed98

# hotel booking (stop the social network first)
cd ../hotelReservation
COMPOSE_PARALLEL_LIMIT=1 docker compose build && docker compose up -d

Second server

wrk2
apt-get install -y git build-essential libssl-dev zlib1g-dev luarocks liblua5.1-0-dev
git clone https://github.com/delimitrou/DeathStarBench.git
cd DeathStarBench && git checkout 6ecb09706140f8730b5385c08f1386c654c3c526
git submodule update --init wrk2 && make -C wrk2
luarocks --lua-version=5.1 install luasocket
cd wrk2

# social network, steps 200 to 3000
for r in 200 300 400 500 600 700 800 900 1000 1200 1500 2000 2500 3000; do
  ./wrk -D exp -t 2 -c 200 -d 300 -L -R $r \
    -s ../socialNetwork/wrk2/scripts/social-network/mixed-workload.lua \
    http://SERVER:8080
  sleep 30
done

# hotel booking, steps 500 to 10000
for r in 500 1000 1500 2000 3000 4000 5000 6000 8000 10000; do
  ./wrk -D exp -t 2 -c 200 -d 300 -L -R $r \
    -s ../hotelReservation/wrk2/scripts/hotel-reservation/mixed-workload_type_1.lua \
    http://SERVER:5000
  sleep 30
done

Web shop

Server under test

bash
apt-get install -y docker.io docker-compose-v2 git
git clone https://github.com/saleor/saleor-platform.git
cd saleor-platform && git checkout ab6315b

# one uvicorn worker per vCPU instead of the default two
cat > docker-compose.override.yml <<YAML
services:
  api:
    command: ["uvicorn", "saleor.asgi:application", "--host=0.0.0.0", "--port=8000",
              "--workers=$(nproc)", "--limit-max-requests=10000"]
    environment:
      - ALLOWED_HOSTS=*
YAML

# production mode, which needs its own signing key
openssl genrsa 2048 > rsa.pem
KEY=$(awk 1 ORS='\\n' rsa.pem)
printf 'DEBUG=False\nALLOWED_CLIENT_HOSTS=*\nRSA_PRIVATE_KEY="%s"\n' "$KEY" >> backend.env

docker compose up -d
docker compose run --rm api python3 manage.py migrate
docker compose run --rm api python3 manage.py populatedb --createsuperuser
# enough stock that orders never run out
docker compose exec -T db psql -U saleor -c "update warehouse_stock set quantity = 10000000"
docker compose restart api worker

Second server

k6 1.3.0
for r in 2 4 6 8 10 15 20 30 40; do
  TARGET=SERVER RATE=$r DURATION=300s k6 run saleor.js
  sleep 30
done
saleor.js
// Saleor shopper load: one iteration = one shopper action, constant arrival rate.
// Mix: 40% browse catalog, 25% product page, 20% search, 15% checkout (create, delivery, payment, complete).
import http from 'k6/http';
import { check } from 'k6';

const API = `http://${__ENV.TARGET}:8000/graphql/`;
const RATE = Number(__ENV.RATE || 10);
const DURATION = __ENV.DURATION || '60s';
const CH = 'default-channel';

export const options = {
  discardResponseBodies: false,
  summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
  scenarios: {
    shoppers: {
      executor: 'constant-arrival-rate',
      rate: RATE, timeUnit: '1s', duration: DURATION,
      preAllocatedVUs: Math.max(50, RATE * 2), maxVUs: Math.max(500, RATE * 20),
    },
  },
};

const ADDRESS = {
  firstName: 'John', lastName: 'Doe', streetAddress1: '1470 Pinewood Avenue',
  city: 'Marquette', postalCode: '49855', country: 'US', countryArea: 'MI',
};
const WORDS = ['juice', 'tee', 'shoe', 'hoodie', 'beanie', 'apple', 'bean', 'code', 'polo', 'cushion'];

function gql(query, variables, name) {
  const res = http.post(API, JSON.stringify({ query, variables }), {
    headers: { 'Content-Type': 'application/json' }, tags: { name },
  });
  let body = null;
  try { body = res.json(); } catch (e) { /* counted below */ }
  const ok = check(res, { [`${name} ok`]: (r) => r.status === 200 && body && !body.errors });
  return ok ? body.data : null;
}

const LIST = `query($ch:String!,$after:String){ products(first:12, channel:$ch, after:$after){
  edges{ node{ id name slug thumbnail{url} pricing{ priceRange{ start{ gross{amount currency} } } } category{name} } }
  pageInfo{ endCursor hasNextPage } } }`;
const PAGE = `query($ch:String!,$slug:String!){ product(slug:$slug, channel:$ch){ id name description seoTitle
  media{url} category{name} attributes{ attribute{name} values{name} }
  variants{ id name sku quantityAvailable pricing{ price{ gross{amount currency} } } }
  pricing{ priceRange{ start{ gross{amount} } } } } }`;
const SEARCH = `query($ch:String!,$q:String!){ products(first:12, channel:$ch, search:$q){ totalCount
  edges{ node{ id name slug thumbnail{url} pricing{ priceRange{ start{ gross{amount} } } } } } } }`;
const CREATE = `mutation($ch:String!,$lines:[CheckoutLineInput!]!,$addr:AddressInput!){ checkoutCreate(input:{
  channel:$ch, email:"[email protected]", lines:$lines, shippingAddress:$addr, billingAddress:$addr }){
  checkout{ id totalPrice{ gross{amount} } shippingMethods{ id } } errors{ field code message } } }`;
const DELIVERY = `mutation($id:ID!,$m:ID!){ checkoutDeliveryMethodUpdate(id:$id, deliveryMethodId:$m){
  checkout{ id totalPrice{ gross{amount} } } errors{ field code message } } }`;
const PAY = `mutation($id:ID!,$amount:PositiveDecimal!){ checkoutPaymentCreate(id:$id, input:{
  gateway:"mirumee.payments.dummy", token:"charged", amount:$amount }){ payment{ id } errors{ field code message } } }`;
const COMPLETE = `mutation($id:ID!){ checkoutComplete(id:$id){ order{ id number } errors{ field code message } } }`;

// Catalog is read once in setup() and shared by all VUs.
let catalog = null;
export function setup() { return { catalog: loadCatalog() }; }
function loadCatalog() {
  const out = [];
  let after = null;
  for (let i = 0; i < 5; i++) {
    const res = http.post(API, JSON.stringify({ query: `query($ch:String!,$after:String){ products(first:100, channel:$ch, after:$after){
      edges{ node{ slug productType{ isShippingRequired } variants{ id quantityAvailable } } } pageInfo{ endCursor hasNextPage } } }`, variables: { ch: CH, after } }),
      { headers: { 'Content-Type': 'application/json' }, tags: { name: 'setup' } });
    const p = res.json().data.products;
    p.edges.forEach((e) => out.push({ slug: e.node.slug, ship: e.node.productType.isShippingRequired, variants: e.node.variants.filter((v) => v.quantityAvailable > 0).map((v) => v.id) }));
    if (!p.pageInfo.hasNextPage) break;
    after = p.pageInfo.endCursor;
  }
  return out.filter((p) => p.variants.length > 0);
}
const pick = (a) => a[Math.floor(Math.random() * a.length)];

function checkout() {
  const p = pick(catalog.filter((x) => x.ship));
  const c = gql(CREATE, { ch: CH, lines: [{ variantId: pick(p.variants), quantity: 1 + Math.floor(Math.random() * 2) }], addr: ADDRESS }, 'checkout_create');
  if (!c || c.checkoutCreate.errors.length || !c.checkoutCreate.checkout.shippingMethods.length) { check(null, { 'checkout_create usable': () => false }); return; }
  const co = c.checkoutCreate.checkout;
  const d = gql(DELIVERY, { id: co.id, m: co.shippingMethods[0].id }, 'checkout_delivery');
  if (!d || d.checkoutDeliveryMethodUpdate.errors.length) { check(null, { 'checkout_delivery usable': () => false }); return; }
  const amount = d.checkoutDeliveryMethodUpdate.checkout.totalPrice.gross.amount;
  const pay = gql(PAY, { id: co.id, amount }, 'checkout_payment');
  if (!pay || pay.checkoutPaymentCreate.errors.length) { check(null, { 'checkout_payment usable': () => false }); return; }
  const done = gql(COMPLETE, { id: co.id }, 'checkout_complete');
  check(done, { 'order placed': (x) => !!(x && x.checkoutComplete.order && x.checkoutComplete.errors.length === 0) });
}

export default function (data) {
  catalog = data.catalog;
  const coin = Math.random();
  if (coin < 0.40) gql(LIST, { ch: CH, after: null }, 'browse');
  else if (coin < 0.65) gql(PAGE, { ch: CH, slug: pick(catalog).slug }, 'product');
  else if (coin < 0.85) gql(SEARCH, { ch: CH, q: pick(WORDS) }, 'search');
  else checkout();
}

Databases

Server under test

PostgreSQL 18.6 from the Ubuntu archive
apt-get install -y postgresql
cat > /etc/postgresql/18/main/conf.d/bench.conf <<CONF
listen_addresses = '*'
max_connections = 300
shared_buffers = 4GB
effective_cache_size = 12GB
maintenance_work_mem = 1GB
work_mem = 16MB
wal_buffers = 64MB
max_wal_size = 32GB
min_wal_size = 4GB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
random_page_cost = 1.1
effective_io_concurrency = 200
CONF
systemctl restart postgresql
# fsync and synchronous_commit stay at their default, on

Second server

HammerDB 6.1.1
V=6.1.1
URL=https://github.com/TPC-Council/HammerDB/releases/download
curl -sL $URL/v$V/HammerDB-$V-Prod-Lin-UBU24-x64.tar.gz | tar xz
cd HammerDB-$V
PGTARGET=SERVER ./hammerdbcli auto build.tcl
for v in 1 2 4 8 16 32 64; do
  PGTARGET=SERVER VU=$v ./hammerdbcli auto run.tcl
  sleep 15
done

The two files below are HammerDB scripts. Save them next to hammerdbcli, put the password of the postgres user in place of PASSWORD, and the commands above run them: build.tcl creates and fills the database once, run.tcl runs one load step with as many virtual users as VU says.

build.tcl
dbset db pg
dbset bm TPC-C
diset connection pg_host $::env(PGTARGET)
diset connection pg_port 5432
diset connection pg_sslmode disable
diset tpcc pg_superuser postgres
diset tpcc pg_superuserpass PASSWORD
diset tpcc pg_defaultdbase postgres
diset tpcc pg_user tpcc
diset tpcc pg_pass tpcc
diset tpcc pg_dbase tpcc
diset tpcc pg_count_ware 50
diset tpcc pg_num_vu 16
puts "BUILD STARTED"
buildschema
puts "BUILD COMPLETE"
run.tcl
dbset db pg
dbset bm TPC-C
diset connection pg_host $::env(PGTARGET)
diset connection pg_port 5432
diset connection pg_sslmode disable
diset tpcc pg_superuser postgres
diset tpcc pg_superuserpass PASSWORD
diset tpcc pg_defaultdbase postgres
diset tpcc pg_user tpcc
diset tpcc pg_pass tpcc
diset tpcc pg_dbase tpcc
diset tpcc pg_driver timed
diset tpcc pg_total_iterations 10000000
diset tpcc pg_rampup 1
diset tpcc pg_duration 2
diset tpcc pg_vacuum false
diset tpcc pg_timeprofile false
diset tpcc pg_allwarehouse true
loadscript
vuset vu $::env(VU)
vuset logtotemp 0
vucreate
set jobid [ vurun ]
vudestroy
puts "JOB $jobid DONE"
pgbench
export PGHOST=SERVER PGUSER=postgres
psql -c "create database pgb"
pgbench -i -s 500 -I dtGvp pgb          # 7.5 GB
for c in 1 4 8 16 32 64; do pgbench -c $c -j $(( c > 2 ? 2 : c )) -T 60 -P 10 pgb; sleep 10; done

# 22 GB: max_wal_size 16GB on every server, so the WAL fits on the smallest disk
psql -c "drop database pgb" -c "alter system set max_wal_size = '16GB'" -c "select pg_reload_conf()"
psql -c "create database pgb"
pgbench -i -s 1500 -I dtGvp pgb
for c in 1 4 8 16 32 64; do pgbench -c $c -j $(( c > 2 ? 2 : c )) -T 60 -P 10 pgb; sleep 10; done

Disk, CPU and network

Server under test

bash
apt-get install -y fio
fio --name=prep --filename=fio.bin --size=4G --rw=write --bs=1M \
    --direct=1 --ioengine=libaio --iodepth=8
run() {
  fio --name=$1 --filename=fio.bin --size=4G --direct=1 --time_based \
      --runtime=30 --ramp_time=3 --group_reporting "${@:2}"
}
run read-qd1        --rw=randread  --bs=4k --ioengine=libaio --iodepth=1
run write-qd1       --rw=randwrite --bs=4k --ioengine=libaio --iodepth=1
run write-fsync     --rw=randwrite --bs=4k --ioengine=sync --fsync=1
run write-fdatasync --rw=write     --bs=4k --ioengine=sync --fdatasync=1
run read-qd32       --rw=randread  --bs=4k --ioengine=libaio --iodepth=32 --numjobs=4
run write-qd32      --rw=randwrite --bs=4k --ioengine=libaio --iodepth=32 --numjobs=4
rm fio.bin

curl -sL https://yabs.sh | bash

Appendix. What the servers reportedtop

What each server reported about itself from the inside, with lscpu, lsblk and the kernel log, on the day of the test. This is what the hypervisor shows to the guest. How the host is built behind it we cannot see.

 ServersCampHetznerVultrDigitalOcean
CPU modelAMD EPYC 4565PAMD EPYC MilanAMD EPYC RomeIntel Xeon Platinum 8358
Base clock4.29 GHz2.20 GHz2.79 GHz2.60 GHz
vCPU as shown6 separate CPUs2 cores x 2 threads2 cores x 2 threads4 cores
L3 cache shown16 MB per vCPU32 MB16 MBnot shown
Disk devicesda, 4K sectorssdavdavda
HypervisorKVMKVMKVMKVM
Steal during testsnear zerozeronear zeronear zero

At Hetzner and Vultr the 4 vCPU are shown as 2 cores with two threads each. At DigitalOcean they are shown as 4 cores. Ours are 6 vCPU of the High Frequency dedicated plan, not tied to host cores, as described in the contenders section. Steal stayed at zero or a few hundredths of a percent on every server through the whole run, so no server was slowed down by its neighbours while we measured.