# 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

  1. A Linux server with Docker and Docker Compose v2 installed.
  2. Access to the platform configuration repository muyantech/platform, and a GitHub SSH key available on the server (see Docker Deployment).
  3. 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
1
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.

  1. Create .env in the platform directory:

    echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
    
    1

    The 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.

    .env holds a secret; do not commit it to git. The .gitignore of the platform repository does not ignore .env, so we recommend running echo .env >> .git/info/exclude to exclude it from your local git and avoid committing it by accident with git add .. .env is not tracked by git, so neither git pull nor git stash touches it.

  2. Add a line to the environment of the server service in docker-compose.yml to pass the variable to the backend container:

          - JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}
    
    1

    The :? syntax makes docker compose up fail immediately when .env does 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_PASSWORD of the database service
  • JDBC_DATABASE_PASSWORD of the server service

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:5005 from the ports of the server service, and remove the -agentlib:jdwp=... option from JAVA_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 the pgadmin service.
  • Make the platform entry point listen on localhost only: change 9080:80 of the proxy service to 127.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
1
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"'
1

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 server as-is into public channels, chat groups or support tickets. The backend prints a block of environment variables to the log every time it starts (after docker 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 docker commands 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_SECRET and 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";
  }
}
1
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:

  1. At the beginning of default.conf (before the resolver line), add:

    map $http_x_forwarded_proto $external_scheme {
      default $scheme;
      https https;
      http http;
    }
    
    1
    2
    3
    4
    5
  2. Replace every $scheme:// in the file with $external_scheme://:

    sed -i 's/\$scheme:\/\//$external_scheme:\/\//g' runtime/proxy/conf.d/default.conf
    
    1
  3. Check 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/api
    
    1
    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
1
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
1

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Last Updated: 9/24/2026, 2:27:35 PM