Going Live on Your Own Server
A local install and a real deployment run the same stack. What changes is that the stack now has a public address, so a handful of settings that pointed at localhost have to point at your domain instead, and the placeholder secrets have to be real.
This page covers a plain server you administer yourself: a VPS, a cloud instance, a machine in your office. If you run Dokploy, use that page instead, because the blueprint handles most of this for you.
This page is written for the local-semantius-idp variant. It works for local-entra-idp too, with the differences listed under Using the Entra Variant. Choose or Build Your Variant lists every variant and which pages apply to each.
Before You Start
You need a server with Docker installed, a domain name, and a DNS record pointing that domain at the server’s IP address. Set the DNS record up first and let it propagate. Certificates are issued by proving you control the domain, so nothing below works until the name resolves.
Then get the files onto the server and move into the variant folder, exactly as you would locally:
git clone https://github.com/semantius/semantius-self-hostedcd semantius-self-hosted/variants/local-semantius-idp./setup-env.shDo not copy a .env from your laptop. The generated secrets are meant to be unique per installation, and the values in it are tied to the database that laptop created.
The Settings That Have To Change
Open .env and set these four. Most of the rest of the file can stay at its default for a first deployment.
| Setting | Set it to | Why |
|---|---|---|
SITE_ADDRESS | yourdomain.com | Tells the front door which name it is serving, and turns on automatic HTTPS |
IDP_BASE_URL | https://yourdomain.com/idp | The address the sign-in system considers its own, including the /idp part |
PUBLIC_WEB_ORIGIN | https://yourdomain.com | Where the app lives, used to check that sign-ins return to the right place |
PUBLIC_API_URL | https://yourdomain.com/gateway/rest | The address the API documentation tells people to call |
IDP_BASE_URL keeps the /idp suffix and PUBLIC_WEB_ORIGIN has no path at all. That difference is not a typo. The first is the sign-in system’s own address, the second is the app’s.
Then decide about the sample data. NWIND=TRUE is on by default and loads the Northwind demo dataset into your database. Remove that line unless you want demo customers and orders in production. It has to happen before your first ./create.sh, because sample data is loaded only when the database is created.
Also confirm your three secrets are the generated ones and not the placeholders from the example file. setup-env handles this, but it is worth a look before a machine goes on the public internet:
grep -E 'IDP_SECRET|POSTGRES_PASSWORD|SEMANTIUS_AUTHENTICATOR_PASSWORD' .envIf any of them still reads dev-only-..., postgres, or devpassword, stop and fix that first. IDP_SECRET in particular has to be right before the first start: it encrypts the keys that sign everyone’s logins, so changing it later signs everyone out and makes the stored keys unreadable.
Keep Your Changes in an Override File
Two of the steps below change which ports the stack publishes. Make those changes in a file of your own called docker-compose.override.yml, next to docker-compose.yml, rather than in docker-compose.yml itself.
Docker Compose reads the override file automatically, so every script picks it up. It is not part of the repository, so updating the stack files with git pull leaves it alone. An edit to docker-compose.yml would instead block the next update with a conflict. Choose or Build Your Variant explains the approach in full, and the alternative of working in a copy of the variant folder.
The file uses the !override tag, which needs Docker Compose 2.24.4 or newer. Check with docker compose version.
Turning On HTTPS
Setting SITE_ADDRESS to a bare domain makes the front door request a certificate automatically, for free, and renew it on its own. For that to work it has to be reachable on the standard web ports, which means publishing ports 80 and 443 rather than the local development port.
Create docker-compose.override.yml in the variant folder with this content:
services:
semantius:
ports: !override
- "80:80"
- "443:443"
The !override tag replaces the published ports instead of adding to them, so port 3000 is no longer published.
Make sure your server’s firewall allows both. Port 80 is not optional even though nothing is served on it: it is how the certificate authority checks that you control the domain, and how visitors who type a bare domain get redirected to the secure version.
Restrict the Database Ports
The stack publishes two database ports on purpose. Port 6432 is the pooled connection for your own applications that talk SQL, and port 5434 is the direct connection for administration and for migrations with the pg_semantius CLI.
They are published on every network interface, so on a public server they are reachable from the internet. They are then protected by a password alone, and 5434 accepts the all-powerful postgres user. On Linux, a firewall such as ufw does not help here, because ports Docker publishes bypass it.
If no other machine needs SQL access, bind both to the server itself. If some do, allow only those machines, for example in your cloud provider’s firewall. Add the postgres and pgbouncer services to the same override file, which then reads in full:
services:
semantius:
ports: !override
- "80:80"
- "443:443"
postgres:
ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5434}:5432"
pgbouncer:
ports: !override
- "127.0.0.1:${PGBOUNCER_PORT:-6432}:5432"
Nothing inside the stack uses those published ports, so the app and the API keep working. Applications and the pg_semantius CLI on the server itself still connect as before, and from your own machine they can connect through an SSH tunnel.
Pin Your Versions
By default the stack downloads the newest version of every piece each time you run ./up.sh or ./create.sh. A plain restart or a reboot does not download anything. But on a server you depend on, the next routine ./up.sh after a settings change would also move you to whatever was published since. Pin the three Semantius pieces in .env before you go live:
SEMANTIUS_DB_VERSION=x.y.z-pg18
SEMANTIUS_APP_VERSION=x.y.z
SEMANTIUS_IDP_VERSION=x.y.z
Replace each x.y.z with a real version from the image’s package page: database, web app, sign-in service. Updating then becomes something you do deliberately: change the tag, run ./up.sh, and roll back by changing it again.
The other pieces, such as the API server and the front door, cannot be pinned from .env yet. Running Your Stack shows how to hold them back with the override file.
Turn On Email
Without an email service the sign-in system runs in a reduced mode: no password resets, no address verification, no notifications. That is fine on a laptop where an administrator creates every account by hand. On a real deployment people will lock themselves out.
Add a Resend API key to .env:
RESEND_API_KEY=re_your_key_here
The features switch on together the next time the stack starts.
Start It
./create.shUse create for a first deployment, because there is no data to lose and you want the database built from scratch. On every later change, use ./up.sh, which applies the new settings and keeps your data.
Every long-running service is set to restart on its own, so the stack comes back after a crash or a server reboot without anyone logging in. Once it holds real data, set up the daily backup described in Running Your Stack.
Then open https://yourdomain.com and complete the setup page. The first person to finish it becomes the administrator, so do this immediately. Until somebody does, that page is open to anyone who finds the address.
Checking It Worked
curl https://yourdomain.com/.well-known/semantius.jsonEvery Semantius deployment publishes a short description of itself at that address. The values in it should carry your domain rather than localhost, which confirms the front door picked up the new settings. If they are empty or still local, the containers were restarted rather than recreated. Run ./up.sh, which recreates them properly.
Using the Entra Variant
Going live with local-entra-idp follows the same steps, in its own folder, with three differences:
- Set
SITE_ADDRESSandPUBLIC_API_URL. Here the API address ishttps://yourdomain.com/rest, the API itself, because API keys and the/gateway/address are part of semantius-idp.IDP_BASE_URL,PUBLIC_WEB_ORIGINand the email section configure semantius-idp too, so they do not apply. Sign-in, password resets and account email are Entra’s. - Update the address registered in Entra. The web app’s redirect address has to match the new domain exactly, or sign-in fails with
AADSTS50011. Addhttps://yourdomain.com/oauth2_callbackunder the Single-page application platform of the web app registration. - Schedule the key refresh. Run
./jwks-refresh.shdaily, as described on the Entra page.
A Note on Serving Several Domains
There is a setting called IDP_DYNAMIC_ISSUER that makes the stack follow whatever domain a request arrives on. It is useful, and it is not safe in the setup described on this page, because a front door configured with one domain still answers to any name pointed at the server. Leave it off here. It is designed for deployments that sit behind a router which only forwards known domains, which is exactly what Dokploy provides.
Next Steps
- Running Your Stack: the everyday commands, backups, and how to update.
- Settings Reference: every value in
.env, grouped by what it does. - Dokploy: if you would rather not administer a server by hand.