Cloud server comparison 2026: Hetzner vs Vultr vs DigitalOcean vs ServersCamp
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.
| ServersCamp | Hetzner | Vultr | DigitalOcean | |
|---|---|---|---|---|
| Founded | 2024 | 1997 | 2014 | 2011 |
| Billing | hourly, prepaid in EUR | hourly, capped at the monthly price | hourly, capped at the monthly price | per second, capped at the monthly price |
| Automation | API, CLI, Terraform | API, CLI, Terraform | API, CLI, Terraform | API, CLI, Terraform |
| Backups | per GB of compressed size, encrypted copies at another provider in another country | automatic, 20% of the server price | automatic, paid add-on | weekly 20% or daily 30% of the server price |
| Snapshots | yes, on a schedule | yes | yes | yes |
| Managed databases | yes, eight engines | no | yes | yes |
| Managed Kubernetes | yes | no | yes | yes |
| Object storage | yes | yes | yes | yes |
| Server disk | network storage, replicated across servers | local to the host | local to the host | local to the host |
| Disk size | any size, paid apart from the server, grows while the server runs | fixed by the plan, more space on a separate volume | fixed by the plan, more space on a separate volume | fixed 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.
| ServersCamp | Hetzner | Vultr | DigitalOcean | |
|---|---|---|---|---|
| Plan | hf-16g-dedicated | CCX23 | voc-g-4c-16gb-80s | gd-4vcpu-16gb-intel |
| vCPU / RAM | 6 / 16 GB | 4 / 16 GB | 4 / 16 GB | 4 / 16 GB |
| CPU we got | AMD EPYC 4565P | AMD EPYC Milan | AMD EPYC Rome | Intel Xeon Platinum 8358 |
| Disk | 100 GB Enterprise | 160 GB local | 80 GB local | 120 GB local |
| Location | Romania | Nuremberg | Frankfurt | Frankfurt (FRA1) |
| Price a month | EUR 93.50 | EUR 86.49 | USD 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.
| Workload | What we used |
|---|---|
| Social network, hotel booking | DeathStarBench at commit 6ecb097, load from its own wrk2 |
| Web shop | Saleor 3.23 in production mode, load from k6 with our shopper script |
| Databases | PostgreSQL 18.6 from the Ubuntu archive, HammerDB 6.1.1, pgbench |
| Disk and CPU | fio, Geekbench 6 from YABS |
| Network | iperf3 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.
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.
| RPS | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| 200 | 3.1 ms | 6.4 ms | 5.1 ms | 7.6 ms |
| 400 | 3.6 ms | 13.7 ms | 6.5 ms | 12.2 ms |
| 500 | 3.9 ms | 29.5 ms | 7.5 ms | 18.1 ms |
| 600 | 4.4 ms | 134 ms | 9.6 ms | 40.9 ms |
| 700 | 4.9 ms | 43.7 s | 13.4 ms | 19.4 s |
| 800 | 5.6 ms | - | 26.4 ms | - |
| 900 | 6.2 ms | - | 97 ms | - |
| 1 000 | 6.9 ms | - | 1.16 s | - |
| 1 200 | 8.7 ms | - | - | - |
| 1 500 | 11.8 ms | - | - | - |
| 2 000 | 103 ms | - | - | - |
| 2 500 | 58 s | - | - | - |
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.
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.
| RPS | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| 500 | 4.5 ms | 13.7 ms | 10.1 ms | 22.0 ms |
| 1 000 | 6.4 ms | 335 ms | 52 ms | 21 s |
| 1 500 | 8.9 ms | 67 s | 7.2 s | - |
| 2 000 | 13.4 ms | - | - | - |
| 3 000 | 130 ms | - | - | - |
| 4 000 | 43 s | - | - | - |
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.
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/s | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| 2 | 190 / 200 ms | 504 / 619 ms | 437 / 482 ms | 487 / 642 ms |
| 4 | 205 / 279 ms | 659 / 983 ms | 480 / 750 ms | 639 / 918 ms |
| 6 | 207 / 294 ms | 1 060 / 1 817 ms | 646 / 1 177 ms | 1 196 / 2 195 ms |
| 8 | 227 / 356 ms | 6.6 s | 981 / 1 827 ms | 14.7 s |
| 10 | 251 / 399 ms | - | 1 268 / 2 175 ms | - |
| 15 | 347 / 626 ms | - | 11.3 s | - |
| 20 | 505 / 927 ms | - | - | - |
| 30 | 3 573 / 7 922 ms | - | - | - |
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.
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 users | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| 1 | 46 391 | 36 418 | 35 261 | 20 978 |
| 2 | 87 982 | 71 359 | 71 050 | 45 292 |
| 4 | 137 701 | 117 850 | 127 921 | 75 521 |
| 8 | 209 235 | 122 356 | 140 965 | 100 234 |
| 16 | 266 316 | 126 430 | 151 230 | 121 408 |
| 32 | 314 055 | 129 596 | 157 002 | 125 479 |
| 64 | 293 191 | 123 206 | 157 395 | 114 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.
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.
| Clients | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| Scale 500 | ||||
| 1 | 672 | 927 | 556 | 202 |
| 4 | 2 496 | 3 066 | 2 186 | 752 |
| 8 | 4 294 | 4 251 | 3 433 | 1 534 |
| 16 | 6 786 | 4 867 | 4 448 | 2 951 |
| 32 | 8 391 | 4 661 | 4 538 | 4 006 |
| 64 | 8 696 | 4 374 | 4 510 | 4 093 |
| Scale 1500 | ||||
| 1 | 555 | 791 | 511 | 216 |
| 4 | 2 040 | 2 682 | 1 893 | 763 |
| 8 | 3 844 | 4 009 | 3 208 | 1 332 |
| 16 | 5 871 | 4 243 | 4 035 | 2 800 |
| 32 | 7 351 | 4 060 | 4 232 | 3 631 |
| 64 | 7 805 | 3 683 | 4 050 | 3 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.
| ServersCamp | Hetzner | Vultr | DigitalOcean | |
|---|---|---|---|---|
| Random read 4k, QD1, median | 159 us | 92 us | 120 us | 109 us |
| Random write 4k, QD1, median | 146 us | 42 us | 43 us | 39 us |
| 4k write + fsync, IOPS | 3 182 | 7 116 | 6 965 | 9 214 |
| 4k write + fdatasync, IOPS | 5 593 | 9 035 | 9 298 | 11 162 |
| Random read 4k, QD32 x 4 jobs, IOPS | 185 292 | 141 722 | 150 920 | 160 732 |
| Random write 4k, QD32 x 4 jobs, IOPS | 200 184 | 134 367 | 149 191 | 139 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
| ServersCamp | Hetzner | Vultr | DigitalOcean | |
|---|---|---|---|---|
| Geekbench 6 single / multi | 3 212 / 11 888 | 1 570 / 3 656 | 1 603 / 5 198 | 1 395 / 2 989 |
| fio 4k mixed, IOPS | 194.1k | 131.2k | 136.7k | 133.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.
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, ms | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| Amsterdam | 35.7 | 9.1 | 7.4 | 8.3 |
| London | 41.7 | 17.5 | 12.9 | 17.6 |
| New York | 121 | 99.7 | 85.4 | 86.2 |
| Tashkent | 121 | 91.2 | 81.4 | 97.9 |
| Singapore | 175 | 163 | 154 | 155 |
| Los Angeles | 187 | 164 | 152 | 149 |
| Same location | 0.42 | 0.22 | 0.22 | 0.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.
| Per EUR a month | ServersCamp | Hetzner | Vultr | DigitalOcean |
|---|---|---|---|---|
| Social network, RPS | 16.0 | 5.8 | 7.4 | 4.2 |
| Hotel booking, RPS | 21.4 | 5.8 | 4.7 | 3.5 |
| Web shop, actions/s | 0.21 | 0.05 | 0.07 | 0.03 |
| HammerDB, NOPM | 3 359 | 1 498 | 1 465 | 887 |
| pgbench scale 1500, TPS | 83 | 49 | 39 | 25 |
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 handled | vs Hetzner | vs Vultr | vs DigitalOcean |
|---|---|---|---|
| Social network | 3.0× | 1.9× | 2.5× |
| Hotel booking | 4.0× | 4.0× | 4.0× |
| Web shop | 5.0× | 2.5× | 5.0× |
| HammerDB, peak | 2.4× | 2.0× | 2.5× |
| pgbench 22 GB, peak | 1.8× | 1.8× | 2.2× |
| pgbench 22 GB, 1 client | 0.7× | 1.1× | 2.6× |
| Disk, single write | 0.3× | 0.3× | 0.3× |
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
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
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
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
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 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
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
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.
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"
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"
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
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.
| ServersCamp | Hetzner | Vultr | DigitalOcean | |
|---|---|---|---|---|
| CPU model | AMD EPYC 4565P | AMD EPYC Milan | AMD EPYC Rome | Intel Xeon Platinum 8358 |
| Base clock | 4.29 GHz | 2.20 GHz | 2.79 GHz | 2.60 GHz |
| vCPU as shown | 6 separate CPUs | 2 cores x 2 threads | 2 cores x 2 threads | 4 cores |
| L3 cache shown | 16 MB per vCPU | 32 MB | 16 MB | not shown |
| Disk device | sda, 4K sectors | sda | vda | vda |
| Hypervisor | KVM | KVM | KVM | KVM |
| Steal during tests | near zero | zero | near zero | near 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.