What runs, and what it needs
| Piece | Image | Job |
|---|---|---|
| App, admin and backend | hoppscotch/hoppscotch (AIO) | With subpath access on, its built-in Caddy serves the app at /, the dashboard at /admin and the backend at /backend, all on container port 80 |
| Database | postgres:15 | Users, workspaces, collections, request history and instance settings |
| Alternative | hoppscotch-frontend, -backend, -admin | Three containers on ports 3000, 3170 and 3100; you route paths to them yourself |
The lede's CPU and RAM figures are the docs' only sizing numbers; none is per user. Database growth follows request history, which an admin can stop logging. We use the AIO image in subpath mode: one port carries everything (aio-subpath-access.Caddyfile in the repo), and the docs say desktop-app support needs subpath access.
The machine
Collections hold the URLs, headers and tokens of a team's APIs. A dedicated server is the whole physical machine: root, no hypervisor, no other customer on the disks Postgres writes to. Classes that fit, in our reading:
- The Xeon E3-1585L v5 cartridge (M710x line): 4 cores at 3 GHz, 16 to 64 GB and NVMe storage, well above the documented floor, when Hoppscotch is the only service on it.
- A single-socket rack server when the same host also runs the team's Gitea or the generators from a load-testing server.
- A dual-socket server when Postgres serves other applications too; the PostgreSQL page covers tuning a database host.
Every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), and so do the m510 and m710x cartridges, so a firewall mistake is fixed from the console, not by a reinstall. Other cartridges have power control only. The X-Gene ARM cartridge is out: it boots only Ubuntu 16.04 and 18.04, while the steps below assume 24.04. Ready (racked) servers take about 30 minutes from payment approval to hand-over; builds to order take 4 to 24 hours.
The .env file, checked against the source
DATABASE_URL: the Postgres connection string.DATA_ENCRYPTION_KEY: 32 characters, used to encrypt sensitive settings;openssl rand -hex 16prints exactly 32.JWT_SECRETis no longer an .env entry. In release 2026.9.0,packages/hoppscotch-backend/src/infra-config/helper.tsgenerates it, and a session secret, from 32 random bytes at first start and stores both encrypted in the InfraConfig table.WHITELISTED_ORIGINS: in production the backend uses this list as its CORS origins (src/main.ts).VITE_BASE_URL,VITE_SHORTCODE_BASE_URL,VITE_ADMIN_URLand the threeVITE_BACKEND_*URLs: where browsers reach each part, the GraphQL WebSocket included.
The docs give values only for three ports; the subpath ones here (/backend/graphql, /backend/v1, /admin) are our derivation from the AIO Caddyfile, which strips /backend before handing requests on. Do not quote values, the docs warn. aio_run.mjs writes the VITE values into the built files at every container start, so a change needs a recreate, not a rebuild.
Install with Compose
- Docker comes first, from docs.docker.com: this setup expects Engine 28 or later, with Compose as a plugin. The block enables ufw with only OpenSSH, IPv6 included, before anything listens.
.envis written only if it does not exist yet. Never run the first half twice by hand on the same server: a fresh.envwould replaceDATA_ENCRYPTION_KEY, and the settings already in the database could no longer be decrypted..envis created at mode 600 before a secret goes in. The passwords pass through a heredoc, never a command line, sopsand shell history miss them; members of the docker group can still read them withdocker inspect.compose.yamlis ours. The repo'sdocker-compose.ymlbuilds from source and publishes Postgres on 5432 with a fixed password; ours pulls a pinned image and publishes only the app, on 127.0.0.1.- Migrations run before the first start. The AIO image's default command only starts the services (
prod.Dockerfile), so we run the docs' command,pnpm exec prisma migrate deploy, as a one-off container, always through-fso it works from any directory. - The last five lines are one chain: an Engine older than 28, an unhealthy database or a failed migration stops everything after it. Only a full run prints ok, meaning the app answers on 127.0.0.1:3180 and nowhere else.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
install -d -m 750 /opt/hoppscotch; cd /opt/hoppscotch
if [ ! -e .env ]; then
install -m 600 /dev/null .env
P=$(openssl rand -hex 24); U=http://localhost:3180
cat >> .env <<EOF
HOPP_VERSION=2026.9.0
PGPASS=$P
DATABASE_URL=postgresql://hs:$P@pg:5432/hs
DATA_ENCRYPTION_KEY=$(openssl rand -hex 16)
ENABLE_SUBPATH_BASED_ACCESS=true
WHITELISTED_ORIGINS=$U,app://localhost_3200,app://hoppscotch
VITE_BASE_URL=$U
VITE_SHORTCODE_BASE_URL=$U
VITE_ADMIN_URL=$U/admin
VITE_BACKEND_GQL_URL=$U/backend/graphql
VITE_BACKEND_WS_URL=ws://localhost:3180/backend/graphql
VITE_BACKEND_API_URL=$U/backend/v1
EOF
fi
cat > compose.yaml <<'EOF'
services:
pg:
image: postgres:15
restart: unless-stopped
environment: {POSTGRES_USER: hs, POSTGRES_DB: hs, POSTGRES_PASSWORD: "${PGPASS}"}
volumes: [db:/var/lib/postgresql/data]
healthcheck: {test: [CMD-SHELL, pg_isready -U hs], interval: 5s, retries: 10}
hoppscotch:
image: hoppscotch/hoppscotch:${HOPP_VERSION}
restart: unless-stopped
env_file: .env
depends_on: {pg: {condition: service_healthy}}
ports: ["127.0.0.1:3180:80"]
volumes: {db: {}}
EOF
c="docker compose -f /opt/hoppscotch/compose.yaml"
[ "$(docker version -f '{{.Server.Version}}' | cut -d. -f1)" -ge 28 ] &&
$c up -d --wait pg &&
$c run --rm --entrypoint sh hoppscotch -c "pnpm exec prisma migrate deploy" &&
$c up -d && $c port hoppscotch 80 | grep -qx '127.0.0.1:3180' && echo ok
Docker publishes a mapping that names no address on 0.0.0.0 and [::]. IPv4 packets to it bypass ufw. IPv6 packets bypass it only if IPv6 networking is enabled in the Docker daemon itself; ip6tables being installed is not the test, and without daemon IPv6 the [::] listener is docker-proxy, a process ufw filters. From Engine 28, a 127.0.0.1 mapping stays local (moby issue 45610).
The first admin, over your own tunnel
Create the administrator before anything is public. On your workstation run ssh -N -L 3180:127.0.0.1:3180 you@server.example.com and browse to http://localhost:3180/admin; the origin matches the VITE values, so the dashboard behaves as if local.
- Onboarding asks which sign-in methods to enable. There is no password option: the auth routes in
src/auth/auth.controller.tsare an email magic link plus GitHub, Google and Microsoft OAuth. - Email needs SMTP: an
smtps://URL and a sender address, or custom host and port settings. A link requested from the dashboard points atVITE_ADMIN_URLplus/enter, one requested from the app atVITE_BASE_URLplus/enter(src/auth/auth.service.ts); both are the tunnel for now. - For OAuth, the docs' callback pattern is the backend URL plus
/v1/auth/github/callback(likewise google, microsoft); under subpath mode we read that ashttp://localhost:3180/backend/v1/auth/github/callbackfor now. - Sign in. Per the docs, the first user who logs in through the admin dashboard becomes the administrator.
Why private: src/infra-config/infra-config.service.ts lets onboarding run again while the user table is empty, so whoever reaches /admin first picks the sign-in providers. In Settings, Data sharing turns off the weekly anonymous instance ping the telemetry page describes.
Going public: DNS, certificate, then config
Caddy is in Ubuntu 24.04's own archive (universe); on 22.04 add Caddy's apt repository first. On the host it is a normal process, so ufw must allow 80 and 443, IPv4 and IPv6 alike. The last lines form a chain: no certificate, a sed that did not land or a failed recreate, and nothing after it runs; ok appears only when all of it worked.
# 1. DNS: A and AAAA records for hopp.example.com -> this server. Wait until both resolve.
ufw allow 80,443/tcp
apt install -y caddy
cat > /etc/caddy/Caddyfile <<'EOF'
hopp.example.com {
@team remote_ip 203.0.113.0/24 2001:db8:10::/48
handle @team {
reverse_proxy 127.0.0.1:3180
}
handle {
respond 403
}
}
EOF
systemctl reload caddy
# 2 and 3. Certificate, then config: each step runs only if the one before it worked
cd /opt/hoppscotch &&
curl -sI https://hopp.example.com | head -1 | grep -q '^HTTP' &&
sed -i 's|http://localhost:3180|https://hopp.example.com|; s|ws://localhost:3180|wss://hopp.example.com|' .env &&
grep -qx 'VITE_BACKEND_WS_URL=wss://hopp.example.com/backend/graphql' .env &&
docker compose -f /opt/hoppscotch/compose.yaml up -d --force-recreate hoppscotch && echo ok
- WebSockets. The app holds a GraphQL WebSocket open to
VITE_BACKEND_WS_URL. The image's own Caddy carries/backendwith a plainreverse_proxy, as does the Caddyfile above; behind nginx, pass the Upgrade and Connection headers yourself. - Who gets in. The admin dashboard page says any user can create an account on the Community Edition; domain allow-listing is an Enterprise feature. The
remote_ipmatcher keeps the instance to your office and VPN ranges; replace the documentation ranges with yours. - After the switch. Change each OAuth callback to
https://hopp.example.com/backend/v1/auth/github/callback(again our reading of the Caddyfile) in Server Settings and at the provider. Your session cookies were set for localhost, so sign in again at the public name; tokens issued from now on name the newVITE_BASE_URLas issuer (auth.service.ts), and an https base URL turns on secure cookies (helper.ts). - The request proxy.
.env.exampledefaults it toproxy.hoppscotch.io; set your own in the dashboard to keep requests inside your network.
Backups that restore
Everything the instance keeps is in Postgres, but a dump alone is half a backup: the settings rows, OAuth and SMTP secrets included, are encrypted with DATA_ENCRYPTION_KEY, and helper.ts stops with an invalid-key error when it cannot decrypt them. The job saves the dump, .env and compose.yaml together and checks that each dump reads back.
install -d -m 700 /var/backups/hoppscotch
install -m 700 /dev/null /opt/hoppscotch/backup.sh
cat > /opt/hoppscotch/backup.sh <<'EOF'
set -euo pipefail
umask 077
d=/var/backups/hoppscotch; t=$(date +%F)
c="docker compose -f /opt/hoppscotch/compose.yaml"
$c exec -T pg pg_dump -U hs -Fc hs > $d/db-$t.dump
$c exec -T pg pg_restore -l < $d/db-$t.dump > /dev/null
cp /opt/hoppscotch/.env $d/env-$t
cp /opt/hoppscotch/compose.yaml $d/compose-$t.yaml
echo "backup $t ok"
EOF
printf '%s\n' '30 3 * * * root umask 077; bash /opt/hoppscotch/backup.sh > /var/backups/hoppscotch/last.log 2>&1' \
> /etc/cron.d/hoppscotch-backup
Copy /var/backups/hoppscotch to another machine regularly; a backup on the same disks dies with them. Left out on purpose: the desktop-app bundle signing key under /data/webapp-server, which prod.Dockerfile says is regenerated when missing; desktop-app users can mount /data as a volume and add it.
- Live restore: stop the app with
docker compose -f /opt/hoppscotch/compose.yaml stop hoppscotch. - Dump the current database under its own name; go on only if this prints ok: `umask 077; docker compose -f /opt/hoppscotch/compose.yaml exec -T pg pg_dump -U hs -Fc hs > /var/backups/hoppscotch/pre-restore.dump && cat /var/backups/hoppscotch/pre-restore.dump | docker compose -f /opt/hoppscotch/compose.yaml exec -T pg pg_restore --list > /dev/null && echo ok`.
- Restore the chosen day over it:
cat /var/backups/hoppscotch/db-DATE.dump | docker compose -f /opt/hoppscotch/compose.yaml exec -T pg pg_restore -U hs -d hs --clean --if-exists, with the.envsaved beside that dump in place. - Migrate and start in one chain:
docker compose -f /opt/hoppscotch/compose.yaml stop hoppscotch && docker compose -f /opt/hoppscotch/compose.yaml run --rm --entrypoint sh hoppscotch -c "pnpm exec prisma migrate deploy" && docker compose -f /opt/hoppscotch/compose.yaml up -d, then sign in. To roll back, repeat steps 3 and 4 withpre-restore.dump. - On a new machine, put the saved
.env(mode 600) andcompose.yamlin/opt/hoppscotchbefore the database container ever starts: the postgres image creates its user fromPOSTGRES_PASSWORDonly on the first start of an empty volume. Then start pg, restore without--clean, and run the same migrate-and-start chain.
Upgrades
GitHub release tags match the image tags; four (2026.8.0 to 2026.9.0) came between late August and the end of September 2026, though the docs' table says quarterly. Major upgrades need migrations, the docs say; we run them every time, since prisma migrate deploy applies only missing ones.
- Read the release notes, run
bash /opt/hoppscotch/backup.shand confirm it prints ok. - Set
HOPP_VERSIONin/opt/hoppscotch/.envto the new tag and rundocker compose -f /opt/hoppscotch/compose.yaml pull hoppscotch. - Stop, migrate and start as one chain:
docker compose -f /opt/hoppscotch/compose.yaml stop hoppscotch && docker compose -f /opt/hoppscotch/compose.yaml run --rm --entrypoint sh hoppscotch -c "pnpm exec prisma migrate deploy" && docker compose -f /opt/hoppscotch/compose.yaml up -d. If the release misbehaves, set the old tag back and restore that morning's dump.
The licence, as the repo states it
The root LICENSE of hoppscotch/hoppscotch is MIT, the README says so too, and the self-host docs call the Community Edition free, open source and MIT-licensed for personal and commercial projects. Our general reading, not legal advice: you may use, modify and redistribute the code, inside a company included; copies passed on keep the copyright notice and licence text, and nothing obliges you to publish changes. The Enterprise Edition is a separate paid, open-core product (SAML SSO, OpenID Connect, audit logs, domain allow-listing) activated with a licence key; the repo has no licence text for it, so read the vendor's terms.
Questions
Can I sign in to self-hosted Hoppscotch without OAuth or SMTP?
No. The Community Edition backend offers an email magic link, which needs working SMTP, and GitHub, Google or Microsoft OAuth. There is no local password. Configure one of them during onboarding in the admin dashboard, before you try to sign in.
Where did the JWT_SECRET variable go?
Current releases generate it. The backend creates a random JWT secret and a session secret at first start and stores them encrypted in the InfraConfig table, which is why DATA_ENCRYPTION_KEY and the dump belong in the same backup.
Does a self-hosted instance report anything to Hoppscotch?
A weekly anonymous instance ping with the edition, version and user and workspace counts, according to the telemetry page, which also says no data from your APIs is captured. Turn it off under Data sharing in the dashboard settings.