# Production Deployment
This page explains how to deploy the platform to a server for production use.
The old installer no longer applies
The all-in-one installer muyantech/installer (Docker Swarm + GlusterFS) described in earlier documentation is based on JDK 11 and cannot run the 1.0 backend (which requires Java 25). Do not use it any more.
The recommended approach is to run the platform on the server with the same Docker Compose configuration as in Docker Deployment, and put a reverse proxy with HTTPS in front of it.
TIP
If you purchased the system from https://muyan.io, you can get help from our technical support team.
# Target Audience
Implementers and operators of this system.
# Prerequisites
- A Linux server with Docker and Docker Compose v2 installed.
- Access to the platform configuration repository
muyantech/platform, and a GitHub SSH key available on the server (see Docker Deployment). - A domain name pointing to the server and an HTTPS certificate. The examples below use
erp.example.com.
# Installation Steps
# 1. Get the Configuration and Log In to the Image Registry
git clone [email protected]:muyantech/platform.git
cd platform
cat token.txt | docker login -u muyantech --password-stdin
2
3
Do not start the platform yet; change the configuration as described in the following steps first.
# 2. Set JWT_SECRET (Required)
JWT_SECRET is the signing key for login tokens. When it is not set, the backend uses the default value built into the image. That value is public, and anyone who has it can forge a login token for any user. It must be set for any deployment exposed to the internet.
Create
.envin theplatformdirectory:echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env1The key must be at least 32 bytes (256 bits). If it is too short, the backend fails to start and the log shows
KeyLengthException: The secret length must be at least 256 bits..envholds a secret; do not commit it to git. The.gitignoreof theplatformrepository does not ignore.env, so we recommend runningecho .env >> .git/info/excludeto exclude it from your local git and avoid committing it by accident withgit add ...envis not tracked by git, so neithergit pullnorgit stashtouches it.Add a line to the
environmentof theserverservice indocker-compose.ymlto pass the variable to the backend container:- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}1The
:?syntax makesdocker compose upfail immediately when.envdoes not set the variable (with the message "Set JWT_SECRET in .env"), so the default value is never used by mistake.
After changing JWT_SECRET, all previously issued tokens become invalid and users must log in again.
# 3. Change the Database Password
In docker-compose.yml, change the following two settings to the same strong password:
POSTGRES_PASSWORDof thedatabaseserviceJDBC_DATABASE_PASSWORDof theserverservice
POSTGRES_PASSWORD only takes effect when the database is initialized for the first time (when runtime/database/data is empty). To change the password after the database has been initialized, first run ALTER USER postgres PASSWORD '...' in the database, then change these two settings.
Also change the password in runtime/pgadmin/pgpass (format database:5432:application:postgres:<password>); otherwise the database connection preset in pgAdmin fails to authenticate.
Change the pgAdmin login password PGADMIN_DEFAULT_PASSWORD as well.
# 4. Restrict Exposed Ports
The default configuration is meant for local development. Before deploying to a server, adjust docker-compose.yml:
- Disable remote debugging: remove
5005:5005from theportsof theserverservice, and remove the-agentlib:jdwp=...option fromJAVA_OPTS. The remote debugging port can execute arbitrary code on the server and must never be exposed to the internet. - Make pgAdmin listen on localhost only: change
"5433:80"to"127.0.0.1:5433:80"and access it through an SSH tunnel when needed; if you do not need it, delete thepgadminservice. - Make the platform entry point listen on localhost only: change
9080:80of theproxyservice to127.0.0.1:9080:80, and let the reverse proxy on the host serve HTTPS to the outside.
PostgreSQL (5432), Redis (6379) and the backend (8080) are not mapped to the host by default; leave them as they are.
Keep GRAILS_ENV at its default value development in docker-compose.yml. This is currently the only runtime configuration of the platform that has been verified; do not change it without understanding the impact.
# 5. Start
docker compose up -d
docker compose ps # startup is complete when server shows healthy
2
After startup, confirm that JWT_SECRET has been passed into the backend container (this only checks whether it is set and does not print the key):
docker compose exec server sh -c 'test -n "$JWT_SECRET" && echo "JWT_SECRET is set" || echo "JWT_SECRET is not set"'
If the output is JWT_SECRET is not set, step 2 did not take effect and the backend is still using the public default key. Check .env and docker-compose.yml, then run docker compose up -d again.
The backend log contains secrets
In the current version, the backend image runs printenv at startup, writing all environment variables (including JDBC_DATABASE_PASSWORD and JWT_SECRET) to the container log. This is an issue in the current version. Please note:
- Do not paste the output of
docker compose logs serveras-is into public channels, chat groups or support tickets. The backend prints a block of environment variables to the log every time it starts (afterdocker compose restart server, another block appears in the middle of the log). When you need to share logs, remove all such blocks, or share only the part around the time of the error. - Restrict the server accounts that can run
dockercommands and read container logs. If logs are forwarded to a centralized logging system, restrict access to it as well. - If you suspect the logs have leaked, replace
JWT_SECRETand the database password as in steps 2 and 3.
# 6. Change the Default Account Passwords
Log in as [email protected] / password, change the passwords of all default users under Business Config > User, and delete the sample users you do not need ([email protected], [email protected]). For the list of default accounts, see Docker Deployment.
# Configuring an HTTPS Reverse Proxy
On the host, terminate HTTPS with nginx (or another reverse proxy) and forward to 127.0.0.1:9080.
# Host nginx Example
server {
listen 443 ssl;
server_name erp.example.com;
ssl_certificate /etc/ssl/erp.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/erp.example.com/privkey.pem;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:9080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
The Host and X-Forwarded-Proto request headers must be passed through. The upload size limit for attachments is 10MB (the backend maxFileSize is 10485760), so the proxy's client_max_body_size must not be smaller than that.
# Making the Platform Recognize HTTPS
The platform's bundled runtime/proxy/conf.d/default.conf uses $scheme to build the backend URL. When HTTPS is terminated by the outer proxy, the container receives http requests, so the backend URL the frontend gets is http://erp.example.com/api, and the browser rejects the requests as mixed content.
Make it use the X-Forwarded-Proto passed in by the outer proxy instead:
At the beginning of
default.conf(before theresolverline), add:map $http_x_forwarded_proto $external_scheme { default $scheme; https https; http http; }1
2
3
4
5Replace every
$scheme://in the file with$external_scheme://:sed -i 's/\$scheme:\/\//$external_scheme:\/\//g' runtime/proxy/conf.d/default.conf1Check and reload:
docker compose exec proxy nginx -t docker compose exec proxy nginx -s reload curl -s -H 'X-Forwarded-Proto: https' -H 'Host: erp.example.com' http://127.0.0.1:9080/backendUrl # should print https://erp.example.com/api1
2
3
4
# Backup
Data to back up:
| Content | Location | Notes |
|---|---|---|
| Database | database container | Export with pg_dump; see the commands below |
| Uploaded attachments | ./runtime/attachments | When attachments use local storage, all files are here |
| Application seed data | ./codes/data | We recommend managing it in your own git repository |
| Deployment configuration | docker-compose.yml, .env, runtime/proxy/conf.d | .env contains secrets; keep it safe |
Database backup and restore:
# Backup (custom format, suitable for pg_restore)
docker compose exec -T database pg_dump -U postgres -Fc application > backup_$(date +%F).dump
# Restore into the database of the same name (overwrites existing data; stop the backend first)
docker compose stop server
docker compose exec -T database pg_restore -U postgres -d application --clean --if-exists --no-owner < backup_2026-01-01.dump
docker compose start server
2
3
4
5
6
7
We recommend running the backup daily with cron and copying the backup files to another machine or to object storage.
# Upgrade
git pull && docker compose pull && docker compose up -d
Locally modified docker-compose.yml and default.conf may conflict during git pull. Run git stash before upgrading, then git stash pop after pulling and check the differences. Back up the database before upgrading, and read the Upgrade Notes.
# Operations Commands
# Check container status
docker compose ps
# View backend logs
docker compose logs -f --tail 200 server
# Restart the backend
docker compose restart server
# Show the running image versions
docker compose images
# Open the database
docker compose exec database psql -U postgres -d application
2
3
4
5
6
7
8
9
10
11
12
13
14