This guide documents a production deployment on a Hostinger VPS where the Hostinger Docker Manager already runs Traefik on ports 80 and 443.
If no other service owns ports 80 and 443, use the default
deploy/docker-compose.production.yml: the project Caddy container can manage
public HTTPS directly. This guide is for the shared-entrypoint setup:
Internet
-> Hostinger Traefik :443
-> 127.0.0.1:8080
-> OpenBcon Caddy :80
-> api:8787 / python:8010
-> PostgreSQL / MongoDB
- A Hostinger VPS with Docker Engine and Docker Compose
- A DNS
Arecord pointing the deployment hostname to the VPS public IPv4 address - No conflicting
AAAArecord pointing the hostname to another server - A working Hostinger Traefik application with ports 80 and 443 available for the hostname
- A server-side OpenAI API key if live generation is enabled
Do not commit .env.production, database passwords, API keys, or the Traefik
ACME storage file to Git.
Create an A record such as:
open.example.com -> YOUR_VPS_IPV4
Check the result from the VPS:
dig +short A open.example.com
dig +short AAAA open.example.comThe A response must be the VPS address. Remove or correct an unexpected
AAAA record before requesting a certificate.
From the repository root:
cp deploy/.env.production.example deploy/.env.production
openssl rand -hex 32Edit deploy/.env.production and set at least:
DOMAIN=your-domain.example
POSTGRES_PASSWORD=use_a_long_random_alphanumeric_value
MONGODB_ROOT_PASSWORD=use_a_different_long_random_value
APP_STATE_ENCRYPTION_KEY=the_64_character_hex_value_from_openssl
OPENBCON_OPENAI_API_KEY=your_server_side_key
SEED_DEMO_DATA=falseTest Mode can start without an SMTP server. It uses the console email provider and logs verification or password-reset previews in the API logs. Before switching to Live Mode, configure SMTP with your provider's values:
PUBLIC_APP_URL=https://your-domain.example
EMAIL_PROVIDER=console
EMAIL_FROM=OpenBcon <no-reply@your-domain.example>
# Set EMAIL_PROVIDER=smtp and uncomment/configure these values before Live Mode.
# SMTP_HOST=smtp.hostinger.com
# SMTP_PORT=465
# SMTP_SECURE=true
# SMTP_USER=no-reply@your-domain.example
# SMTP_PASSWORD=your_mailbox_passwordTo enable Google registration, create a Google OAuth web client and add this authorized redirect URI:
https://your-domain.example/api/auth/google/callback
Then set GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, and
GOOGLE_OAUTH_REDIRECT_URI in deploy/.env.production. Never put these values
in Vite frontend variables or commit them to Git.
Keep APP_STATE_ENCRYPTION_KEY stable. Changing it can make previously
encrypted application settings unreadable.
The default production Compose file maps Caddy directly to ports 80 and 443.
When Hostinger Traefik already owns those ports, change the caddy service to
map only a loopback HTTP port:
ports:
- "127.0.0.1:8080:80"Do not map the project Caddy 443 port in this mode.
The Bootstrap Setup wizard applies this change automatically by mounting the
repository's deploy/Caddyfile.http:
http://{$DOMAIN} {Do not manually change the main deploy/Caddyfile; that file is reserved for
direct Caddy HTTPS mode.
Keep the internal routes pointed at Docker service names:
handle_path /ai-api/* {
reverse_proxy python:8010
}
handle /api/* {
reverse_proxy api:8787
}Do not add reverse_proxy 127.0.0.1:8080 to this file. That would make the
project Caddy proxy to its own host-mapped port.
Open Docker Manager and manage the existing traefik application. The Traefik
configuration needs a router for the public hostname and a service pointing to
the project Caddy port.
The Traefik application should have these static settings:
network_mode: hostcommand:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.file.filename=/etc/traefik/dynamic.yml"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"Mount the dynamic configuration as a file. An absent bind source can be created as a directory by Docker, so create the file before starting Traefik:
volumes:
- traefik-letsencrypt:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/traefik/dynamic.yml:/etc/traefik/dynamic.yml:roCreate /etc/traefik/dynamic.yml:
http:
routers:
openbcon:
rule: "Host(`open.example.com`)"
entryPoints:
- websecure
service: openbcon
tls:
certResolver: letsencrypt
services:
openbcon:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"Because this Traefik container uses host networking, 127.0.0.1:8080 refers
to the VPS host and reaches the project Caddy mapping. If the Traefik
installation does not use host networking, use a host-gateway address that is
reachable from the Traefik container instead.
Make sure dynamic.yml is a regular file, not a directory:
file /etc/traefik/dynamic.ymlThen recreate Traefik without deleting its volumes:
docker compose up -d --force-recreate traefik
docker compose logs -f --tail=100 traefikFor the first deployment, the repository can run a temporary public setup page
so no SSH tunnel is required. Allow inbound TCP port 8090 temporarily in the
VPS firewall, run the setup command below, and open the URL printed in the VPS
terminal. The URL contains a one-time token and expires after 24 hours. Close
port 8090 again after the form is saved; the deployment script stops the setup
container automatically.
After saving, the page continues to display configuration, DNS, service,
HTTPS, and /api/health checks. When deployment succeeds it shows the public
application URL; when it fails it points you to the deploy.sh terminal output
and the retry command.
The deployment script validates the Caddy configuration before starting the
application, waits for the API, Python, and Caddy health checks, and then
verifies the public HTTPS /api/health endpoint. A deployment is not reported
as complete until that public check returns the healthy API response. If the
wizard reports a refused connection, TLS handshake failure, timeout, or HTTP
error, follow the next-step message in the wizard and inspect the Caddy or
Traefik logs before retrying setup.
Run all Compose commands with the production env file. Compose does not
automatically load .env.production for interpolation:
docker compose \
--env-file deploy/.env.production \
-f deploy/docker-compose.production.yml \
up -d --buildOr use the repository deployment script, which supplies the env file for you:
./deploy/deploy.shTo re-run the setup wizard for an existing deployment:
./deploy/deploy.sh --setupOpen the printed address in a browser, for example:
http://YOUR_VPS_IPV4:8090/setup?token=...
Do not leave port 8090 open after setup. It is not an application port and is
only used for the temporary bootstrap wizard.
The setup form uses the admin email as the first OpenBcon administrator login
and asks for a minimum 12-character admin password. After the API is healthy,
the deployment script creates separate administrator rows and an owner
workspace in both the Test and Live PostgreSQL databases. PostgreSQL stores
only the password hash, and the one-time bootstrap password is removed from
deploy/.env.production after successful initialization.
The setup page shows the generated PostgreSQL and MongoDB credentials once. Save them in a password manager and rotate them as soon as the deployment is verified. Use alphanumeric passwords so they can safely be used in PostgreSQL URLs:
cd /opt/openbcon
cp deploy/.env.production "deploy/.env.production.backup.$(date +%Y%m%d%H%M%S)"
new_postgres_password="$(openssl rand -hex 32)"
new_mongodb_password="$(openssl rand -hex 32)"Change the PostgreSQL role while the database container is running. The
administrator username is fixed as admin:
postgres_user="admin"
docker compose --env-file deploy/.env.production -f deploy/docker-compose.production.yml \
exec -T postgres psql -U "$postgres_user" -d postgres \
--set=role_name="$postgres_user" --set=role_password="$new_postgres_password" \
-c "ALTER ROLE :\"role_name\" PASSWORD :'role_password';"Change the MongoDB root password with the fixed administrator username admin:
mongo_user="admin"
mongo_password="$(awk -F= '$1 == "MONGODB_ROOT_PASSWORD" { print $2; exit }' deploy/.env.production)"
docker compose --env-file deploy/.env.production -f deploy/docker-compose.production.yml \
exec -T mongodb mongosh --username "$mongo_user" --password "$mongo_password" \
--authenticationDatabase admin \
--eval "db.changeUserPassword('$mongo_user', '$new_mongodb_password')"Edit deploy/.env.production and update POSTGRES_PASSWORD, the password in
all six PostgreSQL URLs/DSNs, and MONGODB_ROOT_PASSWORD. Do not change only
the env file: the database accounts must already have the new passwords. Then
validate and recreate the application containers without touching data volumes:
docker compose --env-file deploy/.env.production -f deploy/docker-compose.production.yml config >/dev/null
docker compose --env-file deploy/.env.production -f deploy/docker-compose.production.yml \
up -d --force-recreate api python caddy
docker compose --env-file deploy/.env.production -f deploy/docker-compose.production.yml \
psIf a command fails, restore the backup env file and use the old password to
recover before retrying. Never run docker compose down -v during credential
rotation.
Check the services:
docker compose \
--env-file deploy/.env.production \
-f deploy/docker-compose.production.yml \
psInspect logs with the same --env-file flag:
docker compose \
--env-file deploy/.env.production \
-f deploy/docker-compose.production.yml \
logs -f --tail=200 apiTest the private project Caddy mapping from the VPS:
curl -i http://127.0.0.1:8080/api/healthTest the public HTTPS route:
curl -i https://open.example.com/api/healthA healthy Node API returns HTTP 200 and JSON containing:
{
"status": "ok",
"database": "connected"
}Do not use curl -k as the final fix. It only bypasses certificate
verification and can hide a broken Traefik certificate configuration.
git pull --ff-only
docker compose \
--env-file deploy/.env.production \
-f deploy/docker-compose.production.yml \
up -d --buildThe API runs migrations automatically when AUTO_MIGRATE=true. Do not run
docker compose down --volumes during a normal update. That command deletes
the PostgreSQL and MongoDB data volumes.
After the deployment has included the private updater service, future
updates can be started from Admin Console: open Updates, run Check
updates, review the GitHub commit, then choose Install update. The agent
backs up deploy/.env.production, force-switches to main, resets the checkout
to origin/main, cleans untracked checkout files, and runs deploy/deploy.sh; it
does not accept arbitrary Git URLs or shell commands. It may restart the API
briefly, and the Admin Console waits for it to return. If the button says the
service is unavailable, run ./deploy/deploy.sh once manually to create the
updater and its token. Port 8788 must remain private; do not add a public
firewall rule for it.
After the update reports success, the Admin Console clears browser Cache
Storage, service workers, localStorage, and sessionStorage, then reloads
the current page with a cache-busting URL. A locally stored browser session is
cleared too, so sign in again after the reload; database data and Docker
volumes are not removed.
Before production upgrades, back up PostgreSQL and MongoDB and review the
migrations in server/db/migrations/.