Self-host Hyperswitch: demo to production
Your Hyperswitch stack is running on published default credentials with 5432 and 6379 open. Here is the work between that demo and real payments.
What a self-hosted Hyperswitch stack leaves you with
A self-hosted Hyperswitch stack that came up from scripts/setup.sh is a demo, and it is not ready to take money. The gap is not the install. The gap is a set of credentials that are published in a public repository, a Postgres port and a Redis port bound to every interface on the box, a dashboard served over plain HTTP, and a stateful stack that nobody has ever restored from a backup.
Hyperswitch is a payment switch: one API in front of many payment processors, with routing rules that decide which processor handles a given payment. Running it yourself means running a Rust server (upstream calls it the router), Postgres, Redis, a React dashboard called the Control Center, and a browser SDK (software development kit) that renders the payment form. The install itself is three commands:
git clone --depth 1 --branch latest https://github.com/juspay/hyperswitch
cd hyperswitch
scripts/setup.shThe script finds Docker or Podman, asks which profile you want, writes .oneclick-setup.env, and runs docker compose up -d for you. The standard profile gives you the app server on port 8080, the Control Center on 9000, the web SDK on 9050, and Superposition, a configuration service the stack depends on, on 8081. It also creates a dashboard login: demo@hyperswitch.com with the password Hyperswitch@123. That account is the same on every machine that has ever run this script.
Everything below assumes the standard profile, on a VPS with a public IP address, and that you intend to keep this stack rather than delete it on Monday.
The defaults are in a public repository
The router reads config/docker_compose.toml out of your clone, because the compose file mounts ./config into the container and starts the binary with -f /local/config/docker_compose.toml. Open that file and read the parts that matter:
[master_database]
username = "db_user"
password = "db_pass"
host = "pg"
port = 5432
dbname = "hyperswitch_db"
[secrets]
admin_api_key = "test_admin"
jwt_secret = "secret"
master_enc_key = "73ad7bbbbc640c845a150f67d058b279849370cd2c1f3c67c4dd6c869213e13a"Each of those four values does a specific job, so the risk of leaving one alone is specific too. admin_api_key is the value of the api-key header on the admin endpoints, the ones that create merchant accounts and attach payment processors. jwt_secret signs the session tokens the Control Center hands your browser, so anyone who knows it can mint a token the server will accept. master_enc_key is the key your processor credentials are encrypted under before they are written to Postgres. db_pass is the database password, and it appears in docker-compose.yml as well.
Change all four before you create a merchant account and before you attach a single connector. The order matters most for master_enc_key: rows written while the demo key is in place are encrypted under the demo key, so rotating it afterwards leaves you holding data the new key cannot read. Do it on an empty database and there is nothing to migrate.
How do I rotate the database and secret keys?
Generate real values first. Each of these is 32 random bytes printed as 64 hexadecimal characters, which is the shape master_enc_key requires:
openssl rand -hex 32 # master_enc_key
openssl rand -hex 32 # jwt_secret
openssl rand -hex 32 # admin_api_key
openssl rand -hex 32 # database passwordThe database password lives in three places in this repository, and missing one leaves the stack half broken:
POSTGRES_PASSWORDon thepgservice indocker-compose.yml, which is what initialises the database.DATABASE_URLon themigration_runnerservice in the same file, which is the service that applies the schema before the router starts.[master_database]and[replica_database]inconfig/docker_compose.toml, which is how the router connects.
There is a trap in the first one. The official Postgres image applies POSTGRES_PASSWORD only when it initialises an empty data directory. Your pg_data volume already exists, so editing that variable and restarting changes nothing at all, and the router keeps connecting with the old password. Change it in SQL instead:
docker compose exec pg psql -U db_user -d hyperswitch_db \
-c "ALTER USER db_user WITH PASSWORD 'your-new-password';"Keep the secrets out of tracked files. The router builds its configuration with Environment::with_prefix("ROUTER") and __ as the separator (see crates/router/src/configs/settings.rs), which means every key in that TOML file has an environment variable equivalent. [secrets] master_enc_key becomes ROUTER__SECRETS__MASTER_ENC_KEY. Write them into a file git never sees:
ROUTER__SECRETS__ADMIN_API_KEY=<64 hex chars>
ROUTER__SECRETS__JWT_SECRET=<64 hex chars>
ROUTER__SECRETS__MASTER_ENC_KEY=<64 hex chars>
ROUTER__MASTER_DATABASE__PASSWORD=<your new password>
ROUTER__REPLICA_DATABASE__PASSWORD=<your new password>chmod 600 hyperswitch.env
echo 'hyperswitch.env' >> .gitignoreThen attach it to the hyperswitch-server service in docker-compose.yml:
env_file:
- hyperswitch.envThe habit generalises to the rest of your stack, and the reasoning is worth reading once: env files and secrets in Docker Compose.
Restart and check what you changed:
docker compose up -d
curl -fsS http://127.0.0.1:8080/healthcurl -fsS exits non-zero on an HTTP error, so a zero exit status means the router is up and answering. When it is not, docker compose logs hyperswitch-server | tail -n 50 shows a database connection failure, which is what you get when one of the three password locations disagrees with the other two. The visible sign that jwt_secret changed is on the dashboard: your open session stops working and you land back on the login page, because the token sitting in your browser was signed with the key you just replaced.
Why does ufw deny 5432 not close 5432?
Read what the shipped compose file publishes to the host:
pg:
ports:
- "5432:5432"
redis-standalone:
ports:
- "6379:6379"On a laptop that is harmless. On a VPS with a public IP it means the database holding your payment records, and the Redis instance holding live payment state, both answer the internet. The shipped [redis] block sets a host, a port and a pool size, and no password, and the Redis container runs with no requirepass, so anything that can open a TCP connection to 6379 has full access to it.
ufw looks like the fix and is not. Docker publishes a container port by writing a DNAT (destination network address translation) rule into the nat table, and the packet is then filtered on the FORWARD chain, where Docker installs its own rules. ufw's rules sit on the INPUT chain, which a published container port never reaches. sudo ufw deny 5432 reports success and changes nothing.
Fix it where the port is created. The router reaches Postgres over the compose network by the service name pg, and Redis by redis-standalone, so the host mapping serves nobody except whoever is scanning you. Edit docker-compose.yml and bind both to loopback, or delete the two ports: blocks outright:
pg:
ports:
- "127.0.0.1:5432:5432"
redis-standalone:
ports:
- "127.0.0.1:6379:6379"Make this change in docker-compose.yml itself, not in an override file. An override adds; it does not remove. A second ports entry saying 127.0.0.1:5432:5432 does not delete the 5432:5432 already there, and you stay published while believing you are not. Adding a key such as env_file is exactly what an override is good at, and how Compose merges multiple files is worth knowing before you lean on one.
Then confirm it from a different machine, not from the VPS:
nc -vz your.vps.ip.here 5432
nc -vz your.vps.ip.here 6379Both must fail. Run them before you make the change as well, so you see the difference instead of trusting it. On the box, sudo ss -tlnp | grep -E '5432|6379' should now show 127.0.0.1 in the local address column where it showed 0.0.0.0. Give 8080, 8081 and 9050 the same review. Superposition on 8081 has no reason to be public at all.
Terminating TLS in front of the Control Center
The Control Center is a dashboard onto payment data. On port 9000 over plain HTTP, every session token crosses the network in the clear. Put nginx in front of it, get a certificate, and bind the application ports to loopback so the proxy is the only way in. TLS is transport layer security, the thing behind the https:// in the address bar.
server {
listen 443 ssl;
server_name dash.example.com;
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Repeat the block for api.example.com pointing at 127.0.0.1:8080, and for sdk.example.com pointing at 127.0.0.1:9050 if you serve the SDK yourself. If those directives are not familiar, an nginx reverse proxy config explained line by line covers what each one is doing and why the forwarded headers matter.
A certificate alone leaves the stack broken in four ways, and all four are configuration rather than crypto.
config/dashboard.tomlholds[default.endpoints]withapi_url="http://localhost:8080"andsdk_url="http://localhost:9050/HyperLoader.js". Those are URLs the visitor's browser calls, not the container. Left alone, a dashboard served from your domain tells each visitor's browser to call port 8080 on their own machine. Set them tohttps://api.example.comandhttps://sdk.example.com/HyperLoader.js.[user] base_urlinconfig/docker_compose.tomlishttp://localhost:9000. The server uses it to build links it sends out, so point it athttps://dash.example.com.[cors]ships withwildcard_origin = true, so the router accepts requests from any origin. Setwildcard_origin = falseand list your own:origins = "https://dash.example.com,https://sdk.example.com". The commented-out line inconfig/development.tomlshows the exact format.[user]also carriesforce_two_factor_auth = falseandforce_cookies = false. Once the certificate is in place, turn both on. TOTP (time-based one-time password) is already enabled as a dashboard feature, so this costs you one setting and a phone.
Run docker compose up -d after editing anything under config/, because the router reads those files at startup only.
PCI scope: what changes when card data touches your VPS
PCI DSS (payment card industry data security standard) applies to systems that store, process or transmit cardholder data. The word that decides how much work you have taken on is scope: which machines sit inside it. Self-hosting a payment switch is a decision about scope, and it deserves to be made on purpose rather than discovered later.
Hosted checkout and redirect flows keep your box out of the card path. The customer types the card number on the processor's own page. Your server sees an identifier and a status. The PAN (primary account number, the long number on the front of the card) never reaches your VPS.
Collecting the card on your own page puts the box in the path. When the Hyperswitch web SDK renders the card fields on your site, the card details go to your app server, which passes them to the processor. That server is now handling cardholder data, on an image you pulled, on a network you configured, backed up to storage you chose. Everything in scope has to be defended and evidenced, and there is a lot of it.
Saved cards raise the third question, storage, which needs a vault. Hyperswitch has one, the Hyperswitch Card Vault, described upstream as "a highly performant and a secure vault to save sensitive data such as payment card details". The compose stack does not run it. What the stack ships is this:
[locker]
host = ""
mock_locker = true
locker_enabled = truemock_locker = true with an empty host means no real vault is attached. It exists so the demo flows complete end to end. Real cards do not belong behind it.
What no post can tell you is that some configuration is "PCI compliant". Compliance is an assessment of a whole environment against the requirements that apply to your integration and your transaction volume, and the people who decide are your acquiring bank and, past a threshold, a QSA (qualified security assessor). What a post can tell you is where the boundary sits. Route card entry to the processor and your VPS stays outside the card data path. Render the fields yourself and it does not, whatever else you configure.
What 4 GB and 2 vCPU actually run
The standard profile runs six long-lived containers: Postgres, Redis, the Rust router, the Control Center, the web SDK and Superposition. Plan on 4 GB of RAM and 2 vCPU, which is the figure self-hosting write-ups quote for this stack, and treat it as a floor rather than a target. Postgres and a compiled Rust binary are both modest on their own. Run them beside the dashboard, the SDK server and a configuration service on a 2 GB plan and you start seeing containers exit with code 137, which is how the shell reports that the kernel's OOM (out of memory) killer stopped the process.
The full profile is a different machine entirely. It adds Grafana, Loki, Prometheus, Tempo, Kafka, ClickHouse, OpenSearch and Vector. That is an analytics platform bolted to a payment switch, and it is not a small-VPS workload. Pick the standard profile and add monitoring later, deliberately.
There is a second question hiding inside the sizing one: whether the database belongs in the compose stack at all once it holds payment records. The trade-offs are laid out in running the database in Docker or on the host. One detail is specific to this stack and worth knowing before you go looking: the pg_data volume is mounted at /var/lib/postgresql, the parent directory, rather than at the data directory itself.
Backing up a stack that now holds payment records
Take a logical dump from inside the container, over the Unix socket, so no password appears on a command line or in your shell history:
sudo install -d -m 700 /srv/backups/hyperswitch
docker compose exec -T pg pg_dump -U db_user -d hyperswitch_db --format=custom \
> /srv/backups/hyperswitch/hyperswitch-$(date +%F).dumpA dump on its own is not a restore. Two things have to travel with it. The first is master_enc_key, because your processor credentials are encrypted under it: a database without that key is a pile of ciphertext, and a copy of the key kept on the same server protects nothing. The second is config/docker_compose.toml together with your hyperswitch.env, which are what a fresh box needs to come back up in the same shape.
Prove the dump loads, on a schedule you will actually keep:
docker compose exec -T pg createdb -U db_user hyperswitch_restore_test
docker compose exec -T pg pg_restore -U db_user -d hyperswitch_restore_test \
< /srv/backups/hyperswitch/hyperswitch-2026-08-17.dump
docker compose exec -T pg psql -U db_user -d hyperswitch_restore_test -c '\dt' | head
docker compose exec -T pg dropdb -U db_user hyperswitch_restore_test\dt listing the payment tables means the dump is real. An empty list means you have been backing up nothing, which is a thing to find out on a quiet Tuesday rather than during an incident. The general routine for stateful stacks, including where the volumes fit, is in backing up and upgrading a Docker Compose stack.
Two image tags that will move under you
Look at the router and dashboard services in docker-compose.yml and you find pull_policy: always sitting next to rolling tags: hyperswitch-router:standalone and hyperswitch-control-center:latest. Together those mean an ordinary docker compose up -d, run for an unrelated reason, re-pulls and can move your payment switch onto a build you did not choose. Pin the router:
hyperswitch-server:
image: docker.juspay.io/juspaydotin/hyperswitch-router:v1.125.0-standaloneEach router release publishes its exact pull command, so copy the tag from the releases page rather than from here. As of August 2026 the current release is v1.125.0, published on 2026-07-10. The -standalone suffix is the build without AWS SES support, which is the variant the compose file already uses. Do the same for the Control Center: replace latest with the version tag from its own releases page, where v1.38.7 was published on 2026-08-05.
postgres:latest is the same problem with a worse ending. The day that tag's major version increments, the container starts against a data directory written by the previous one and refuses:
FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 17, which is not compatible with this version 18.1.The version numbers depend on when it happens to you. Find the one you are running now and pin to it:
docker compose exec pg psql -U db_user -d hyperswitch_db -c 'SHOW server_version;'Set image: docker.io/postgres:17 with your own number. A major upgrade then becomes something you schedule: dump, change the tag, start an empty volume, restore.
The upgrade itself runs in this order.
- Take a dump and verify it loads into a throwaway database.
- Change the pinned tags in
docker-compose.yml. - Run
docker compose pulland thendocker compose up -d. - Watch
docker compose logs -f migration_runnerfinish, then confirmcurl -fsS http://127.0.0.1:8080/healthsucceeds.
Step 4 carries the warning. migration_runner applies schema migrations before the router starts, so once a new version's migrations have run, going back to the previous image is not a docker compose up away. Your rollback path is the dump from step 1, which is why step 1 is step 1.
When a payment switch is the wrong answer
If you use one payment processor and expect to keep using one, use that processor's own SDK and skip everything above. A switch is an abstraction over multiple processors. With one processor it adds a service, a database, a Redis instance, a dashboard and an upgrade schedule, in exchange for an abstraction you are not using.
The reasons to run one are concrete. You have two or more processors live and want routing rules that choose between them. You want a declined authorisation retried on a second processor. You are negotiating rates and need to move volume without shipping code. You sell into a market where your main processor does not support the local payment methods.
There is also a cost that only appears after you deploy. A self-hosted switch sits in the payment path, so when your VPS is down, checkout is down, and that is now your pager rather than somebody else's. A processor's hosted checkout comes with an availability record and an operations team. Yours comes with you. Run the switch when the routing is worth that trade, and use the hosted sandbox while you are still deciding.
FAQ
Is a self-hosted Hyperswitch setup PCI compliant?
No configuration is compliant on its own, and nobody can grant that from a config file. What you control is scope. If card entry happens on the processor's hosted page, the PAN never reaches your VPS and your server stays outside the card data path. If the web SDK renders the card fields on your site, the card details pass through your app server, and that machine is in scope. The shipped [locker] block has mock_locker = true and an empty host, so no real vault is attached and it is not a place to keep cards. Who assesses you, and against which requirements, is a question for your acquiring bank and a QSA (qualified security assessor).
Which default credentials must I change before taking a real payment?
Four, all of them public in the repository: [master_database] password (db_pass), [secrets] admin_api_key (test_admin), [secrets] jwt_secret (secret) and [secrets] master_enc_key. Change or delete the dashboard user demo@hyperswitch.com that scripts/setup.sh creates with the password Hyperswitch@123. Set master_enc_key before you attach any payment processor, because connector credentials are encrypted under it, so rotating it later leaves rows the new key cannot read.
Why is Postgres still reachable from the internet after I ran ufw deny 5432?
Because the compose file publishes 5432:5432, and Docker's published ports do not pass through ufw's rules. Docker DNATs the packet in the nat table and filters it on the FORWARD chain, while ufw's rules live on INPUT. The fix is to stop publishing the port: change the mapping in docker-compose.yml to 127.0.0.1:5432:5432, or remove the ports: block, since the router reaches Postgres by service name over the compose network. Do the same for 6379:6379. Confirm with nc -vz your.vps.ip.here 5432 run from a different machine.
How much RAM does a self-hosted Hyperswitch stack need?
The standard profile runs six long-lived containers: Postgres, Redis, the Rust router, the Control Center, the web SDK and Superposition. Treat 4 GB of RAM and 2 vCPU as the floor. Containers exiting with code 137 mean the kernel's OOM killer stopped them, and that is what a 1 GB or 2 GB plan looks like under this stack. The full profile adds Grafana, Loki, Prometheus, Tempo, Kafka, ClickHouse, OpenSearch and Vector, and belongs on a much larger machine.