One VPS, Four Environments, No Cookie Banner — How I Host a Next.js App with Self-Hosted Keycloak

For most web applications, the cookie banner question comes up too late. The application is built, an analytics script is in, the login page comes from a cloud service, and suddenly the very first page view needs a consent dialog that nobody planned for.

The DI² project turned the question around. GDPR without a cookie banner was a design goal from day one, and the infrastructure was built so that the application needs no consent. It runs on a server in the EU that hosts four environments side by side. Sign-in is handled by a self-hosted identity provider, a reverse proxy sits in front of it, and the only cookies that reach the browser are the ones the service could not work without. This article describes that setup, the decisions behind it, and the price a database developer with no operations background paid for it. The promise that AI-assisted development makes everything easy does not include this part. The agent wrote the scripts. The decisions, the operations, the responsibility and the monthly bill stayed with the maintainer.

The essentials up front:

  • The design goal comes first: No consent for cookies means no trackers, no third-party scripts in the browser, a self-hosted identity provider instead of a managed one, and a server in the EU. That is an architecture decision at the start, not a legal question at the end.
  • One VPS, four environments: Development, integration, test and production run on one machine, each with its own checkout, its own database, its own realm and its own local port. Only the proxy is reachable from outside.
  • Run the identity provider yourself: Keycloak provides one realm per environment. An idempotent script of 477 lines creates it, and a second run changes nothing. The maintainer did not write a single line of it.
  • The edge is manual work: nginx terminates TLS and routes by hostname, the existing hosting provider keeps only DNS and mailboxes, the VPS provider blocks outbound mail ports, and roughly once a month an update run covers seven layers from the kernel down to the npm dependencies.
  • Four cookies beyond the login, no tracking: two for sign-in, two for preferences the user chose explicitly. Short-lived helper cookies exist only during the sign-in detour. The privacy policy names each one and classifies them as strictly necessary under § 25 (2) no. 2 TDDDG.
  • The price is operations and money: 8 GB of RAM for everything, secrets in two places, documentation that lags behind reality, and a monthly bill made up of server and AI subscription. A cloud provider usually carries the first, and the second appears in no vibe-coding promise.

Prerequisite: The case is a Next.js application with Auth.js and Keycloak on a Hetzner cloud server. No operations background is required, and terms such as reverse proxy, realm and OIDC are explained briefly where they first appear. The article is not legal advice.

Contents

A cookie banner is not a component you add at the end. It is the consequence of decisions made earlier. A web analytics service sets cookies, so it needs consent. A login page hosted by a cloud provider sets its cookies under the provider’s domain, and the provider decides which ones. An embedded video loads a third party’s scripts before the visitor has clicked anything. Each of these decisions brings the dialog with it, and each is expensive to undo later.

In the DI² project, an ETL generator built on Next.js and PostgreSQL, the maintainer therefore reversed the order. The goal “no consent requirement” was fixed before the first component was chosen, and it forced four decisions. The first concerns the browser: no trackers and no analytics services run there. The second concerns the pages themselves: they load no third-party scripts. The third concerns sign-in: it goes through a self-hosted identity provider, so that the login page and the cookies it sets stay under the project’s own control. And the fourth concerns location: the server sits with a German provider in a data center in the EU. For this building block, the hosting of application, database and sign-in, that removes the transfer of personal data to a US cloud provider. Other data flows, say to a mail relay or to services that a later feature brings in, are not covered by this and have to be assessed on their own.

A word on the reach of this goal. “Without a cookie banner” means that the application needs no consent for its cookies. That obligation is not in the GDPR but in § 25 TDDDG, the German law that governs access to the end user’s device, and it applies regardless of the technique: the browser’s local storage falls under it too, not only the cookie. The GDPR comes in afterwards, for the processing of the personal data. No banner therefore does not mean the GDPR is taken care of. Processing the data behind the login still needs its legal basis, a privacy policy and, wherever a service provider is involved, a contract with that provider. The banner is only the most visible symptom, and that is exactly why it works as a design goal: whoever wants to avoid it has to make the decisions behind it early.

The rest of this article is the consequence of those four decisions. They are the reason the application runs on a single VPS, the reason Keycloak is self-hosted, and the reason the edge made of proxy, DNS and mail took so much manual work.

One VPS, Four Environments

The entire application runs on a single virtual server at Hetzner. Four environments live on that machine: development, integration, test and production. Each environment has its own checkout of the repository, its own database, its own realm in the identity provider and its own container listening on its own local port. The application’s four databases sit in one shared PostgreSQL instance, which itself runs as a container. Since the move in April 2026, Keycloak has its own instance next to it. None of these containers is reachable from outside. The only public component is the reverse proxy, which distributes requests by hostname.

Heavily simplified and as of September 2026, the setup looks like this:

Architecture diagram: browser and DNS at All-Inkl on the left, the Hetzner VPS on the right with nginx as the only public component, below it four app containers dev, int, test and prod on one PostgreSQL instance with four databases, next to it Keycloak with its own database, at the bottom GitHub Actions via SSH and an SMTP relay on port 587.

The environments are stages, not copies. Code moves from one stage to the next by hand. Development, integration and test build from the same development branch, and production builds from the main branch. Every promotion is a manually triggered GitHub Actions workflow. It connects to the server over SSH, sets the checkout to the requested state and rebuilds the container. What the lifecycle from object file to deploy looks like as a whole is described in the hub article Database CI/CD with PostgreSQL. The quality gate with a throwaway database before every database deploy is shown in GitHub Actions for Postgres Deploys, and the directory convention of the database layer is explained in Deploying a SQL Schema Without a Migration Tool. This article repeats none of that.

A single server forces three things that four servers would solve on their own. First, it demands port discipline. Every environment needs its own local port, and if an environment’s configuration file is missing, its container falls back to the default port that another one already occupies. Second, all services share a single RAM budget, which the cost section comes back to. Third, it demands a clean separation of layers from the start, because every later reorganization happens on the live server. Exactly such a reorganization took place in April 2026. The directories moved into a layout that allows more than one app on the same server. The PostgreSQL instance shared until then was split into an app database and a Keycloak database, and Keycloak got its own infrastructure location. A script of 412 lines in eight phases did the job. It asks before every destructive step and takes backups in the second phase.

The script came out of an agent session. The assignment, reconstructed from the feature description, looked roughly like this:

The server will later host more than one app. To prepare for that, the four
app directories move into a layout with an app level, the shared Postgres
instance is split into an app database and a Keycloak database, and Keycloak
gets its own infrastructure location.
This runs once on the live server, and all four environments are affected.
Requirements: backup before the first destructive step, every phase asks
before it runs and can be skipped individually, and at the end every
environment answers its version endpoint again.
Document the rollback path before anything runs.

The prompt describes the goal, the constraints and the acceptance criterion. The split into eight phases, the backup logic and the confirmation before each step were the agent’s proposals. The maintainer approved them phase by phase on the live server.

Running Identity Yourself: Keycloak

The first version of the application, in March 2026, signed users in with e-mail and password directly in the app. From April 2026, Keycloak took over that task as the sole identity provider. Since then the app itself knows no passwords. It redirects the user to Keycloak via OpenID Connect. After signing in there, the browser comes back with a one-time code, which the application exchanges server-side for Keycloak’s tokens. From the result, Auth.js builds the application’s own session.

Two terms are needed for this. OpenID Connect, or OIDC, is the protocol by which an application delegates sign-in to an identity provider and receives a verified identity back from it. A realm in Keycloak is the container that manages a set of users, credentials, roles and groups. That is how the Keycloak documentation defines it. The DI² project has one realm per environment. A test user in the integration environment does not exist in production, and a role change in the test realm does not touch production.

Clicking four identically configured realms together by hand would have been the obvious solution, and the surest road to drift. Instead, a Bash script of 477 lines creates every realm through Keycloak’s admin API. For each realm it creates an OIDC client for the app, with authorization code flow and PKCE. It creates the four realm roles, switches on brute-force protection and makes the e-mail address the username. It enables the password reset link, attaches the project’s own login and e-mail theme, and sets German as the default language with English as the second. The script is idempotent. A second run on a finished realm changes nothing and reports for every object that it already exists and is being skipped. After every change to the script, all four realms run through it in a loop, and only the new settings are added.

This assignment is reconstructed too, from the feature description and the header of the script:

Keycloak runs in a container behind nginx, and admin access is in place.
I need one realm per environment (dev, int, test, prod) with the same
setup: an OIDC client for the app with authorization code and PKCE, the
roles administrator and user, e-mail as the username, password reset
allowed, our login and e-mail theme, German as the default language and
English in addition.
All of it as a script against the admin API, not as a click-through guide.
Acceptance: a second run on a finished realm changes nothing and reports
for every object that it was skipped.
Suggest what you would build this with, and give your reasons.

The maintainer has neither Bash at this depth nor Keycloak’s admin API. The prompt therefore only fixes what has to be there at the end and explicitly delegates the how. Bash with curl and jq was the agent’s proposal. The acceptance criterion of the second run was the maintainer’s requirement, and it is the part that separates the script from a click-through guide.

Two migrations came on top, both on running realms. The first was the switch from password login in the app to the external identity provider, after which every user had to sign in once more. The second was the change from the two-role model with administrator and user to a four-role model on 22 April 2026. The migration script lifts existing administrators to the highest role and initially puts all previous users on the lowest. It removes the old roles only once nobody holds them any more, and it warns if a realm ends up with fewer than two active administrators. Anyone who needs more than read access is promoted by hand afterwards.

Three things in this section the maintainer decided himself. There is one realm per environment rather than a shared one. The identity provider is self-hosted rather than bought as a cloud service. And every change to a realm goes through the script, not through the admin console. Everything else, from the script to the theme, came out of agent sessions and was accepted by login test.

Reverse Proxy, DNS and Mail: The Edge of the Infrastructure

reverse proxy is a web server that accepts requests from the internet and passes them on internally to the right service. In the DI² project, nginx plays that role, and it is the only component reachable from outside. It terminates TLS with certificates from Let’s Encrypt. Every subdomain has its own certificate, the four app environments as well as the identity provider. Renewal runs through a cron job. That this path actually works showed in July 2026, when three certificates were renewed without anyone touching them.

Distribution happens by hostname. Every environment has its own subdomain, all of them point to the same server IP, and nginx decides from the requested name which local port the request goes to. Two details here are more than routing. The containers listen only on localhost, and that is the real isolation. On top of that, nginx adds a secret header to every forwarded request, and the app rejects requests without it. That is a second layer for the case that the first one ever fails. And the return path of the login, meaning the address the browser is redirected to after signing in at Keycloak, is throttled to ten attempts per minute and IP address. That is a second layer of protection next to the brute-force protection in the realm.

All-Inkl, the hosting provider of this blog, plays only two supporting roles in this setup. It manages the DNS records, the five A records that point to the server, and it provides the mailboxes. Nothing else, including the hosting of the application, happens there.

The mail role brought the project’s first real trap. Keycloak sends password reset e-mails, and the obvious solution was to use the existing hosting provider’s SMTP server for that. The first send attempt on 20 April 2026 ran into a timeout. There was no error message and no refused connection, the packets simply vanished. The reason lies with the provider. Hetzner blocks outbound ports 25 and 465 on new cloud servers by default, to prevent spam. That is how the Hetzner Cloud FAQ describes it, as of 7 September 2026. That the block shows up as a timeout rather than a refused connection is the observation from the project, not a statement from the documentation. The unblock can be requested in the cloud console, but only after the first paid invoice and at least one month as a customer. The other route is a mail relay addressed on port 587 or 2525. Whoever does not know about the block looks for the error in Keycloak first, then in the credentials, and at the provider last.

Updates Every Month: Operating System, Docker, PostgreSQL, Keycloak

A server you run yourself has no provider patching it overnight. Since May 2026, the DI² project has therefore run an update check roughly once a month, and four runs are documented: on 2 May, 30 May, 19 June and 3 August. Every run checks two sides. On the development side it checks the application’s dependencies, the declared container versions and the versions of the GitHub Actions. On the server side it checks what is actually running there, from the kernel down to the container images. The table shows which layers come up, what concretely happened in the project, and who would take over the layer in a managed alternative made of Vercel and a hosted database. The assignment holds for exactly this combination, and it shifts with other services:

LayerExample from the project, 2026With Vercel and a hosted database
Operating system and kernelbatches of 14, 8 and 22 packages with reboot, in May, June and Augustprovider
Docker engine, nginx, Certbotin the project, Docker and nginx come through the operating system’s package manager, Certbot as a snap with auto-updatenot applicable, there is no server of your own
Container imagesPostgres 17.9 to 17.10 with eleven security fixes according to the release announcement, one Keycloak patch, one critical patch of the MySQL sample databasedatabase patches with the provider, major upgrades with the customer, a self-hosted identity provider likewise
App dependenciesNode 20 to 24 in May, six npm findings in August, five of them high, closed with one update rundeveloper, in both models
GitHub Actionscheckout and setup actions raised to version 5 in Maydeveloper
Certificatesrenewal via cron, in July 2026 without interventionprovider
Secretsrotation across five classes times four environments after an advisory from the Next.js vendor in Maydeveloper

The mechanics are the same division of labor as in the rest of the infrastructure. An agent skill reads the declared versions from the project files, never from memory, and compares them with the upstream state. On the server, a script runs that only reads and changes nothing, and the maintainer hands its output back to the session. The agent sorts the findings into critical, recommended, small and large. Applying is done by hand, and every run leaves a line in the report’s history. On top of that there is a security audit as a skill of its own. It runs quarterly and before every production deploy, most recently on 5 September 2026 as a full audit across ten areas.

The discipline is a queue, not a zero balance. On 3 August, the Postgres minor version of 14 May with its eleven security fixes and the Keycloak patch were still open, and both stand in the report with a date. For this project, the identity provider is the place where a patch backlog weighs heaviest, because every sign-in of all four environments depends on it. That is exactly why it appears in the as-is table of every run. A managed alternative takes the first two rows of the table off your hands, and the database patches and the certificates along with them. The remaining rows stay with the developer in every model.

In the end, the design goal can be checked against a list. Four cookies remain in the browser beyond the sign-in flow, and the privacy policy, as of September 2026, names each one with purpose, lifetime and legal basis:

Cookie, beyond the loginPurposeLifetimeWhen set
__Secure-authjs.session-tokensession token as a JWT, keeps the sign-in, contains user ID, e-mail and name8 hoursafter login
__Host-authjs.csrf-tokenCSRF token, protects the sign-in flow against forged requestssessionat login
sidebar_stateremembers whether the navigation is expanded or collapsed7 daysin the signed-in area, on click
di2_public_langremembers the language chosen for the public pages12 monthswhen the language is chosen

The two login cookies come from Auth.js 5 and carry its default names. For cookies, the project’s configuration follows the library’s defaults, and those set HttpOnly, Secure and SameSite=Lax. HttpOnly denies scripts in the browser access to the cookie, Secure restricts its transmission to HTTPS connections, and SameSite=Lax withholds it from requests that a foreign site triggers. During the sign-in detour through Keycloak, Auth.js additionally sets short-lived helper cookies, for the return address, the PKCE verifier and the state of the flow. They are not in the list above, carry the same attributes, expire after 15 minutes at the latest, and belong to the login itself. The two preference cookies store a choice the user made explicitly and nothing else. No cookie contains an identifier that follows a visitor across pages, and none is set by a third party.

The legal basis for this is § 25 (2) no. 2 TDDDG, the German Telecommunications Digital Services Data Protection Act, which was called TTDSG until May 2024. Under it, consent is not required if storing information on the end user’s device is strictly necessary for the provider of a digital service to deliver a digital service explicitly requested by the user. There is a strong case for the login cookies: there is no sign-in without a session cookie, and no secure login without CSRF protection. The concrete classification still depends on the implementation and the service, not on the cookie type. For the two preference cookies, it is a classification that the project justifies in its privacy policy. The user explicitly chose the language and the state of the navigation, and the cookie stores exactly that choice and nothing else. Whoever does not share that classification can carry the language choice in the URL and not remember the navigation state. That costs convenience, not a banner.

The limit of this statement matters. It holds for the infrastructure and sign-in layer, according to the privacy policy and the section cited. It is not an overall verdict on the application and not legal advice. And it holds only as long as none of the triggers is added that would force consent. The list of those triggers is stored in the project as a rule: web analytics with cookies, marketing pixels, embedded third-party content with its own cookies, live chat widgets, A/B testing tools, session recording. As soon as one of them is to be built in, the project stops the implementation. Then a banner is added, the foreign script is loaded only after consent, and the privacy policy is extended by the provider. The same rule applies to this blog.

The Cost Side

What a cloud provider otherwise carries invisibly lies out in the open with this setup. Three items were the most expensive in the first six months, a fourth occurs in every variant, and a fifth hits only the managed one.

The shared memory: The server has 8 GB of RAM. On it run the four app environments, two PostgreSQL instances, one with the four app databases and one for Keycloak, plus Keycloak itself, three sample databases for the product demo, and a private Nextcloud stack with an office integration that has nothing to do with the project. In August 2026 that was a good dozen containers, ten of them for DI². The four app containers themselves are the smallest items, at around 300 MB combined. The big ones are the office integration at about 1.6 GB, the SQL Server sample database at 824 MB and Keycloak at 754 MB. When memory runs short after several deploys in a row, the kernel swaps out memory pages that have not been touched for a while. For a Keycloak that has nothing to do between two sign-ins, that is almost all of its pages. On 28 August 2026, 754 of the Keycloak process’s 802 MB sat in swap. The first login call took 9.6 seconds, every further one 0.19 seconds. How this finding was made, and why the first suspicion of the new version was wrong, is told in this branch’s diagnostics article. Here only the cost side counts: an identity provider on a shared server needs a RAM budget you have to leave to it.

Secrets in two places: Several secrets inevitably exist twice. The database password sits in the container’s environment file and in the role in PostgreSQL. The client secret for Keycloak sits in the same environment file and in the realm. A token for a cron job sits in the environment file and in the crontab. Then there are the secrets in GitHub, once per environment, so that the deploy can set them. If a value is rotated in one place and not in the other, the login fails with a message that can have three other causes as well. The answer was in two parts. A table in the operations rule names both locations for every secret, the drift symptom and the way to reconcile them. A check script prints, for every mandatory variable, only whether it is set, and never its value.

Documentation that lags behind reality: In one place, the project’s setup guide still describes PostgreSQL as installed directly on the server. In fact it runs as a container. The operations rule, which was verified against the container list, gets this right. Both documents exist side by side, and the rule in the project has since been that the verified operations rule wins in case of conflict, and that every diagnostics session starts with the container list instead of guessing from memory. That is not the carelessness of a single author. It is the normal aging of documentation in a project where the infrastructure is built by agent sessions that do not update the guide with every change.

The money: The promise that AI development makes everything easy has spread under the label vibe coding, and it sounds like a single subscription. In fact there are several items, and the free tiers of the managed services are free only as long as you accept their restrictions. The table sets the setup as built against a managed alternative, with monthly list prices as of 8 September 2026. All providers named list their prices in US dollars, and Hetzner, which bills in euros, publishes a dollar list price alongside. The table uses the dollar prices throughout:

ItemAs builtManaged alternative
HostingHetzner cloud server of the class 4 vCPU, 8 GB RAM, 80 GB: 9.99 USD excluding VAT for new orders since 15 June 2026, 7.99 USD beforeVercel Pro: 20 USD per team member, usage-based beyond that. The free tier is restricted to personal, non-commercial use
Databaseincluded in the server, as a containerSupabase Pro: 25 USD, every further project from 10 USD in compute. The free tier allows two projects with 500 MB and pauses them after a week without access
Identity providerKeycloak, included in the server, paid for in memoryhosted identity provider with a free tier up to a few thousand users and a login page under a foreign domain
DNS and mailAll-Inkl, included in the existing hosting packagelikewise
DeployGitHub on the free tierGitHub on the free tier
AI toolClaude with Claude Code: Pro 20 USD, Max from 100 USDlikewise

Without the AI subscription, the self-built setup stays under ten dollars a month. The managed alternative comes to 45 USD, and that is with a single Supabase project and without an identity provider. For the comparison, Supabase is assumed with four separate projects, one per environment. That is an assumption for strictly separated databases, not a requirement of the provider. The sum for that is 20 USD for Vercel, 25 USD for Supabase Pro with the first project and three times 10 USD for the further projects, 75 USD in total, still without an identity provider. The AI subscription comes on top in both columns and is the largest item. Which tier is needed depends on the daily hours of use, because both tiers cap usage in five-hour windows and additionally per week, and how far you get with that depends on the model and the length of the conversations. Claude Code has no free tier. And the managed column brings back exactly the question this article avoids.

The third-country question: Managed does not mean forbidden. It means that the conditions are not met by themselves. Vercel and Supabase are US companies with EU regions and a data processing agreement, but the default setting is not the one an operator in the EU needs, and at Vercel, for instance, functions run in the US as long as you do not choose another region. Region, contract and the evidence for both have to be established by the user, and an EU region lowers certain transfer risks without taking care of the remaining obligations. On top of that, the user carries two risks that no contract removes. Today’s legal basis for transfers to the US, the Data Privacy Framework of 2023, is in force. A legal challenge against it has been pending at the European Court of Justice since October 2025, and the two predecessors of this basis fell there. The CLOUD Act can, under certain conditions, oblige US providers to hand over data even when it is stored outside the US. Why an EU region is therefore not the same as an EU provider gets an article of its own. Whoever does not want to carry these risks cannot avoid the provider question, and for this project the answer was: no US provider in the chain. On your own server in the EU, the question does not arise for the infrastructure, and that is the real difference between the two columns, not the price.

The first three items have something in common. With a managed service, the provider carries them. Here the maintainer carries them, even though an agent wrote the scripts and configurations. The fourth item occurs in every variant. The fifth is the price of the managed variant, and it appears on no invoice.

What a Cloud Provider Otherwise Carries

The account can be kept in two columns. On one side is what the setup delivers. It gives control over every cookie and every request a visitor’s browser makes. It keeps data, sign-in and mail at one location in the EU. It provides a login page in the project’s own design and without a foreign domain, and it carries four environments for the price of a single server. The design goal “GDPR without a cookie banner” is not just met but verifiable. The cookie list has four entries, and the list of triggers that would force a banner is empty.

On the other side is operations, with everything the update section and the cost section have listed, from the mail block to the monthly bill. That column appears in no vibe-coding promise. It is the part of development that an agent speeds up but does not take over, and for a database developer with no operations background it was the part with the most to learn. The work has not disappeared, it has moved from the application into operations, and it has stayed there. For the project as a whole, the hub article Agentic Coding from a User’s Perspective describes that, and this article expands its infrastructure chapter. The real price of this setup is not 9.99 USD a month. It is the responsibility for patch levels, backups, certificates, mail, secrets and recovery, and that appears in no promise. Whoever takes the same route should know this column before the first decision, not after it.

Rebuild Checklist: From Empty Server to First Login

Whoever wants to rebuild the setup needs none of the scripts from this article, but the order. Ten steps, and each one notes where the DI² project got stuck or what turned out in hindsight to be mandatory:

  1. Order the server: from an EU provider, with at least 8 GB of RAM for four environments and an identity provider. Sample databases and private services count toward the budget.
  2. Set DNS: at the existing hosting provider, one A record per subdomain, all pointing to the same IP, one of them for the identity provider.
  3. Set up nginx and Certbot: one certificate per subdomain, renewal via cron, and after the first run, execute the renewal dry run once.
  4. Start Docker and PostgreSQL: one instance as a container, one database per environment. As a container from the start, otherwise the setup guide drifts away from reality.
  5. Start Keycloak as its own stack: with its own database and its own subdomain, then one realm per environment, created by a script that changes nothing on the second run.
  6. Bind one app container per environment: on localhost only, with its own environment file and its own port. The check script for the mandatory variables belongs to day one, not to the first drift error.
  7. Create the nginx vhosts: one per subdomain to the matching port, with the secret proxy header and a rate limit on the login’s return path.
  8. Set up the deploy via GitHub Actions: one workflow per environment, set the checkout over SSH, build and start the container. Production builds only from the main branch.
  9. Test mail delivery before anyone resets a password: at Hetzner, ports 25 and 465 are blocked, and the route goes through a relay on port 587 or the requested unblock.
  10. Write the privacy policy with the cookie list, restore a backup of both databases once, and fix an update rhythm: all three before the first user arrives. They are not afterthoughts but part of the build.

FAQ

Does a web application with a login really need no cookie banner?

Not for the cookies without which the login does not work. Session token and CSRF token are strictly necessary for a service the user explicitly requests, and for those § 25 (2) no. 2 TDDDG requires no consent. For cookies that store an explicitly chosen preference, such as the language choice, that is a classification you should justify in the privacy policy. The banner arrives the moment a tracker, a marketing pixel or embedded third-party content is added. The statement refers to the infrastructure layer and does not replace a legal review of the entire application.

Why four environments on one server instead of four servers?

The reasons are cost and the simplicity of the edge. One server means one reverse proxy, one set of certificates, one PostgreSQL instance and one Keycloak for all stages. The separation does not come from hardware but from separate checkouts, separate databases, separate realms and separate local ports. The price is a shared RAM budget, and it becomes visible as soon as a service is swapped out between two calls. For a solo project with a handful of users the setup holds, and for more load, production would be the first candidate for a machine of its own.

Why Keycloak and not Auth0, Clerk or another hosted identity provider?

A hosted identity provider brings its own login page under its own domain, and with it its own cookies. The credentials then sit with a third party, often outside the EU. With Keycloak, the login page, users and roles stay on your own server, the theme is freely customizable, and one realm per environment is a script run. The price is around 750 MB of memory, regular updates, and the obligation to keep realm provisioning as a script rather than as a sequence of clicks.

What does All-Inkl still do when the server is at Hetzner?

It takes care of two things, DNS and mailboxes. The A records for all subdomains point from the existing hosting provider to the server at Hetzner, and the e-mail addresses keep running through its mailboxes. What does not work is sending from the server through the hosting provider’s SMTP server on port 465, because Hetzner blocks that port on new cloud servers, as of September 2026. That takes either the requested unblock or a relay on port 587 or 2525.

Can Vercel and Supabase be used in a GDPR-compliant way?

Not by themselves, but only through your own work. Both are US companies with EU regions and a data processing agreement, and Vercel is additionally certified under the EU-US Data Privacy Framework. You establish the conditions by choosing the region, concluding the contract, documenting the transfer basis and extending the privacy policy. Whether that is sufficient in a given case is a legal assessment that this article does not make. A residual risk remains, because the two predecessors of today’s basis fell before the European Court of Justice, a legal challenge against the current one is pending at the European Court of Justice, and the CLOUD Act can, under certain conditions, oblige US providers to hand over data, regardless of the region. Whoever does not want to carry that hosts with an EU provider in the EU, as this project does. The details of both risks are in the separate article on the third-country question.

What would be different in a second build?

Four things, and all of them come from the cost section. PostgreSQL would run as a container from the start, so that setup guide and reality do not diverge. The check script for the environment files would arrive on day one, not after the first drift error. The RAM budget per service would honestly account for the product demo’s sample databases, because together they need more memory than the application itself. And the provider’s mail block would be read before the first password reset test, not after it.

Hub of this branch:

Cluster hub:

CI/CD and deploy:

Getting started: