Skip to main content

Docker Compose deployment

This page describes how to deploy FoxPlan on a single server with Docker Compose, behind Traefik, which handles routing and TLS termination.

For a production target with scaling and high availability, see Kubernetes deployment.

Two versions​

Local trialEnterprise installation
ForEvaluating FoxPlan on a workstationA shared server, production
Filesa single docker-compose.ymldocker-compose.yml, install.sh and .env files
ConfigurationNoneDomain, TLS, SMTP, secrets
Accesshttp://localhost:8090, from the workstation onlyhttps:// your domain
EmailsCaptured by a built-in test mailboxSent by your SMTP server

Both versions use the same images and are installed in two distinct steps:

  1. Download the images: the only time Internet access is needed, from the server itself or from a transfer machine;
  2. Setup — local trial or enterprise installation: the stack starts only on the images already present. No registry is contacted; if an image is missing, nothing starts.
FoxPlan image registry

The registry address is communicated to you by FoxPlan. Set it in the FOXPLAN_REGISTRY variable, in the .env file placed next to docker-compose.yml — for the trial as for the enterprise installation. Read access is open: no credentials and no docker login are needed.

ImageRole
foxplan_prodApplication
foxplan_prod-docsThis documentation
foxplan_prod-exportSchedule export (PDF, PNG, Excel, iCal)
foxplan_prod-mspMS Project and Primavera import and export

latest follows the most recent published version; each version also carries an immutable tag, to pin in production. Third-party images (MongoDB, Redis, Traefik…) are pulled from Docker Hub, not from this registry.

Step 1: download the images​

Downloading the images is the only time the stack needs Internet access. Once the images are on the server, FoxPlan no longer contacts any registry: neither at startup, nor during operation, nor when a container restarts.

Images to download​

The four FoxPlan images above, in the version you deploy, and the following third-party images, all pulled from Docker Hub:

ImageLocal trialEnterprise installation
traefik:v3.6.4YesYes
mongo:8.2.7YesYes (MONGO_IMAGE)
redis:7.4YesYes (REDIS_IMAGE)
axllent/mailpit:v1.27YesNo: your emails go through your SMTP server
dxflrs/garage:v2.1.0YesOnly if you add Garage — see File storage
curlimages/curl:8.14.1Yes: initialises Garage on first startNo
rclone/rclone:1.68.2NoOnly to back up Garage

With your own S3 storage, neither Garage, curl nor rclone is needed. If you change MONGO_IMAGE or REDIS_IMAGE in .env, download the image you set there.

Network flows​

Outbound flows issued by the Docker engine during the download, all over HTTPS (TCP/443):

DestinationRoleImages concerned
FoxPlan registry hostImage indexfoxplan_prod, -docs, -export, -msp
FoxPlan registry token serviceAnonymous access tokensame
FoxPlan registry object storageImage layers: the registry redirects downloads to this storagesame
registry-1.docker.io, auth.docker.ioDocker Hub: image index and access tokenThird-party images
production.cloudflare.docker.com, production.cloudfront.docker.comDocker Hub image layers (CDN)same
app.fox-plan.comDownload of the deployment files on this pageLocal trial (curl)

If your firewall filters by image (registry proxy, Harbor, Nexus or Artifactory allow-list…), allow the names and versions above.

The three FoxPlan registry hosts are communicated to you along with its address. A firewall that only allows the registry domain lets the index through but blocks the layers: the pull then hangs or fails on a timeout. The redirects to the object storage and the CDN must be allowed as well.

The operational flows — ACME certificate authority, SMTP server, S3 storage, SSO providers — are described in the network flow matrix.

From the server​

If the server can reach the registries for the duration of the download:

FOXPLAN_REGISTRY=<address communicated by FoxPlan>
V=latest # in production: the version pinned in FOXPLAN_VERSION
IMAGES=(traefik:v3.6.4 mongo:8.2.7 redis:7.4)
for i in foxplan_prod foxplan_prod-docs foxplan_prod-export foxplan_prod-msp; do
IMAGES+=("$FOXPLAN_REGISTRY/$i:$V")
done
# Local trial: add the test mailbox and the built-in storage.
# IMAGES+=(axllent/mailpit:v1.27 dxflrs/garage:v2.1.0 curlimages/curl:8.14.1)
# Enterprise installation with Garage:
# IMAGES+=(dxflrs/garage:v2.1.0 rclone/rclone:1.68.2)
for i in "${IMAGES[@]}"; do
case "$i" in
*-export:*|*-msp:*) docker pull --platform linux/amd64 "$i" ;;
*) docker pull "$i" ;;
esac || echo "ÉCHEC : $i"
done

The foxplan_prod-export and foxplan_prod-msp images exist for amd64 only: the loop always pulls them for that architecture. On an ARM server or an Apple Silicon Mac, they run under emulation. An ÉCHEC line flags an image that was not downloaded: fix it before moving on to step 2.

Access to the registries can then be closed again: step 2 no longer needs it.

From an independent environment​

Sensitive sector: retrieve the images outside your information system

If your organization belongs to a sensitive sector — defense, healthcare, energy, finance, public administration, operator of vital importance — do not open these flows from the production server or from your internal network.

Retrieve the images on a machine independent of your information system (a dedicated workstation or transfer environment, with no access to your internal networks), check them, then bring them in through your usual channel: internal registry, or an archive transferred on controlled media.

# 1. On the transfer machine, outside your information system.
# Build IMAGES exactly as above (trial or enterprise, with or without Garage):
# an image forgotten here will be missing at startup.
# --platform: the architecture of your target server (amd64 in most cases).
for i in "${IMAGES[@]}"; do docker pull --platform linux/amd64 "$i"; done
docker save -o foxplan-images.tar "${IMAGES[@]}"
sha256sum foxplan-images.tar > foxplan-images.tar.sha256

# 2. Checks according to your policy: antivirus, vulnerability scanning
# (Trivy, Grype…), image digests (docker images --digests).

# 3. On the target server, without Internet access.
sha256sum -c foxplan-images.tar.sha256
docker load -i foxplan-images.tar

The loaded images keep their original name: step 2 finds them locally. If you go through an internal registry, retag them to it (docker tag, docker push), pull them from the server, and set this registry in FOXPLAN_REGISTRY, MONGO_IMAGE and REDIS_IMAGE. The production server then has no Internet flow at all for its images.

Step 2: setup​

Both versions start with --pull never: Docker Compose uses the images present on the machine and contacts no registry. If one is missing, the stack does not start and the missing image is named: redo step 1 with the right list, then start again.

Without this option, a missing image would be downloaded silently — or, on a server without Internet access, startup would hang on a timeout.

Local trial​

On a workstation with Docker Desktop — or Docker and Docker Compose v2 — a single file is enough. The only value to provide is the registry address, communicated by FoxPlan:

mkdir foxplan-essai && cd foxplan-essai
curl -O https://app.fox-plan.com/docs/onpremise/essai/docker-compose.yml
echo "FOXPLAN_REGISTRY=<address communicated by FoxPlan>" > .env

# Checks that all the images are present, then starts without downloading anything.
MISSING=$(docker compose config --images | while read -r i; do
docker image inspect "$i" >/dev/null 2>&1 || echo "$i"
done)
if [ -n "$MISSING" ]; then echo "Missing images, nothing was started:"; echo "$MISSING"
else docker compose up -d --pull never; fi

Without FOXPLAN_REGISTRY, docker compose stops with a message asking for it. The preliminary check stops everything before a single container is created; --pull never alone would also refuse to download, but only after creating part of the stack.

Startup takes about a minute. Then:

AddressContent
http://localhost:8090FoxPlan — on first launch, the diagnostic page, then the creation of your account
http://localhost:8090/mailpit/The emails sent by FoxPlan, including the sign-up code
http://localhost:8090/docs/This documentation

No email leaves the workstation: a test mailbox, Mailpit, captures them all.

The trial ships a file storage, Garage: project documents, client contract attachments and profile photos work without any configuration. On an Apple Silicon Mac, the two export services run under amd64 emulation: this is expected, and a little slower.

Apple Silicon Mac: MongoDB must be arm64

Only the two export services need to run as amd64. If the other images were pulled or loaded as amd64 — for example from an archive prepared for an amd64 server — MongoDB does not start: from version 5 on, it requires AVX instructions, which emulation does not provide. The mongo log then shows "MongoDB 5.0+ requires a CPU with AVX support", the application fails with MongoTimeoutException and http://localhost:8090 returns 502.

A plain docker pull fixes nothing: the amd64 image already present is considered up to date. Explicitly request the arm64 version, then restart:

. ./.env # FOXPLAN_REGISTRY
for i in traefik:v3.6.4 mongo:8.2.7 redis:7.4 \
$FOXPLAN_REGISTRY/foxplan_prod:latest $FOXPLAN_REGISTRY/foxplan_prod-docs:latest; do
docker pull --platform linux/arm64 "$i"
done
docker compose up -d --pull never

Create the first account​

The trial sends no real email: the sign-up code is read in Mailpit.

  1. Open http://localhost:8090: a diagnostic page opens (First setup screen) and shows you whether everything works correctly, without asking you anything — see what is checked.
  2. If everything responds, click Create the first instance with my account and enter an email address whose domain exists — your work address, for example; a made-up domain is refused. The message stays in Mailpit: nothing leaves the workstation.
  3. In another tab, open http://localhost:8090/mailpit/: the message containing the sign-up code arrives there within a few seconds.
  4. Go back to FoxPlan and enter this code. The wizard then walks you through your profile, your company, the modules and the invitations, and finally asks you to choose your password.

This first account is the administrator of the instance. Invitations and password resets sent afterwards also arrive in Mailpit.

The trial listens on port 8090 rather than 80: macOS does not let a non-administrator user open port 80 on the 127.0.0.1 address alone, and Docker then shows no error — the page simply does not respond.

Port 8090 already taken? Choose another one at startup:

FOXPLAN_PORT=8091 docker compose up -d --pull never

then open http://localhost:8091.

To stop everything: docker compose down (data kept), or docker compose down -v to start from scratch.

To update the trial, redo step 1, then run docker compose up -d --pull never again. A docker compose up -d without the option downloads the latest version of the FoxPlan images itself: handy on a connected workstation, but step 2 is then no longer offline.

The trial data is attached to the project name foxplan-essai, not to the folder: a new folder resumes the existing trial, with its accounts. To start from scratch, use docker compose down -v.

For evaluation only

The trial only listens on 127.0.0.1 — it can only be reached from the workstation itself — and uses well-known passwords, written in the file. Do not expose it on a network and do not enter real data into it: to welcome users, move to the enterprise installation below.

Enterprise installation​

Files to download:

Place the files in the same directory. All configuration happens in the .env* files: docker-compose.yml stays untouched, which makes upgrades painless.

The rest of this page covers the enterprise installation: architecture, prerequisites, sizing, then the installation procedure itself.

Architecture​

Internet or internal network
(DNS: DOMAIN → this server)
│ 80 / 443
▼
┌─────────┐ TLS termination
│ Traefik │ redirect 80 → 443
└────┬────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
/ (app:5000) /docs (docs:8080) /gantt-export:3200
product-server docs gantt-export
│ │
├──► mongo (internal network) └──► gantt-msp (internal)
└──► redis (internal network)
ServiceRoleRequired
product-serverFoxPlan applicationYes
docsThis documentation, served under /docsNo
gantt-exportSchedule export to PDF / PNG / Excel / iCalNo
gantt-mspMS Project and Primavera import and exportNo
mongoDatabaseYes
redisCache and shared sessionsYes
S3 storageFiles: documents, attachments, profile photos — yours or Garage, see File storageNo

Without gantt-export, the application works normally: only the graphical Gantt exports are unavailable. Without S3 storage, only the files are.

Prerequisites​

  • A Linux server with Docker and Docker Compose v2;
  • A domain name whose DNS record points to this server;
  • Ports 80 and 443 reachable by your users — no others, SSO included: see Ports and SSO;
  • The images, downloaded in step 1: from the server, with temporary outbound access to the FoxPlan registry and Docker Hub, or imported from a transfer environment;
  • Your license;
  • For files, an S3-compatible storage — yours or Garage, see File storage;
  • Depending on the chosen TLS mode, outbound access to the certificate authority (see below).

Ports and SSO​

Enterprise SSO requires no additional port, whether it is the standard Microsoft or Google sign-in or a custom SSO. The return of a sign-in is carried by the user's browser to https://YOUR_DOMAIN/login/oauth2/code/…, on the same port 443 as the rest of the application: the identity provider never opens a connection to your server.

DirectionPortBetweenPurpose
Inbound443 (TCP)User workstations → TraefikThe application and the SSO return
Inbound80 (TCP)User workstations → TraefikRedirect to 443; HTTP-01 challenge in acme mode
Outbound443 (TCP)product-server container → identity providerToken exchange, signing keys, profile — domains in the network flow matrix

Do not publish the application's port 5000: everything goes through Traefik, which terminates TLS. If your identity provider listens on a port other than 443 — Keycloak on 8443, for example — that is the port to open outbound, from the server to the provider.

Standard SSO: Microsoft and Google​

Enabled by the TENANTID_AZURE, CLIENT_ID_AZURE, GOOGLE_CLIENT_ID… variables in .env_application. The redirect URL is built from BASE_URL:

https://YOUR_DOMAIN/login/oauth2/code/azure
https://YOUR_DOMAIN/login/oauth2/code/google

Declare it exactly as is in the application registered with Microsoft Entra or Google. Microsoft and Google only accept https URLs.

Custom SSO: configured in the application​

Okta, Keycloak, Microsoft, Google or any OIDC provider declared in Enterprise SSO. The redirect URL is then rebuilt from the incoming request: Traefik passes the original address to the application — X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port headers — and FoxPlan derives https://YOUR_DOMAIN/login/oauth2/code/<identifier> from it. The Check configuration button shows the exact URL to declare.

Keep public port 443

Traefik replaces the X-Forwarded-* headers with what it sees itself. If users reach FoxPlan through another public port (:8443), or if a device placed in front of Traefik terminates TLS and forwards plain HTTP to it, FoxPlan computes a redirect URL that differs from the browser's: the provider rejects the sign-in (redirect_uri mismatch). A load balancer placed in front of Traefik must therefore forward traffic over HTTPS on port 443, or as TCP without decrypting it, keeping the original domain name.

Sizing​

The values below are slightly above what FoxPlan measures in operation, to keep some headroom on the main components.

For the local trial​

ResourceRecommended
Processor4 vCPU
Memory allocated to Docker8 GB (6 GB minimum)
Disk15 GB free (images included)

When idle, the trial uses about 1.5 GB, of which 1.1 GB for the application. In Docker Desktop, the allocated memory is set in Settings → Resources. The trial sets no per-container limit: it adapts to the memory given to Docker.

For the enterprise installation​

UsersProcessorMemoryDisk (SSD)
Up to 1004 vCPU16 GB100 GB
100 to 5008 vCPU32 GB, with 2 application instances200 GB
BeyondSee Kubernetes deployment

The disk includes the database, the files stored in Garage if it runs on the same server, and room for local backups before they are transferred. To place the database on a dedicated disk, see Volumes and disks.

Container memory limits​

The enterprise docker-compose.yml sets a memory limit per container. Without a limit, the application's JVM and MongoDB each size themselves on all of the server's memory — a quarter for the JVM, half for the MongoDB cache — and end up competing for it. With a limit, each adapts to its own.

ContainerDefault limitVariable (.env)
product-server (application)6 GBFOXPLAN_MEM_APP
mongo4 GBFOXPLAN_MEM_MONGO
gantt-export2 GBFOXPLAN_MEM_EXPORT
redis1 GBFOXPLAN_MEM_REDIS
gantt-msp512 MBFOXPLAN_MEM_MSP
docs256 MBFOXPLAN_MEM_DOCS
traefik256 MBFOXPLAN_MEM_TRAEFIK

That is 14 GB, for a 16 GB server: enough is left for the operating system. On a 32 GB server, double FOXPLAN_MEM_APP and FOXPLAN_MEM_MONGO. The application uses 75% of its limit for its Java heap; MongoDB automatically reserves half of its own, minus 1 GB, for its cache.

The processor is not limited: application startup and export generation benefit from it, and a limit would slow them down without protecting anything.

Several application instances​

For a large number of users, run several instances of the application on the same server. Traefik detects them and spreads requests across them; sessions are shared in Redis, so a user can move from one instance to another without being logged out. Scheduled tasks (reports, routines, synchronisations) take a lock in Redis: only one instance runs them, however many there are.

In .env:

FOXPLAN_APP_REPLICAS=2

then docker compose up -d. Each instance has its own FOXPLAN_MEM_APP limit: the memory required grows accordingly. Example for a 32 GB server with 2 instances:

FOXPLAN_APP_REPLICAS=2
FOXPLAN_MEM_APP=6g # per instance: 12 GB in total
FOXPLAN_MEM_MONGO=8g

That is about 24 GB of limits in total, with the other containers: there is still headroom for the system.

When should you add an instance? When the application container's processor stays saturated at peak hours (docker stats), in practice beyond a hundred or so users active at the same time. A second instance also keeps the service available if one of them restarts.

Two limits, specific to a single server:

  • an upgrade recreates all instances at the same time: the interruption is short, but it exists;
  • MongoDB remains a single node, on the same server.

Beyond 500 users, or for real high availability (several servers, rolling deployment without interruption), move to Kubernetes deployment.

Volumes and disks​

The MongoDB database and the files stored in Garage are kept in host directories, never in Docker volumes. A Docker volume disappears with docker compose down -v or a docker system prune --volumes; a host directory does not. Placed on a dedicated disk, it also survives a reinstallation of Docker or of the system.

DataIn the containerLocation on the hostVariable (.env)
MongoDB database/data/dbDirectory, mandatoryFOXPLAN_MONGO_DATA
Files in Garage (optional)/var/lib/garage/meta, /var/lib/garage/dataDirectory, meta and data subfoldersFOXPLAN_GARAGE_DATA
Redis (cache, sessions)/dataredis_data Docker volume, or a directoryFOXPLAN_REDIS_DATA (optional)
Traefik certificates/certs, /acme./traefik/ folder of the installation—

Without FOXPLAN_MONGO_DATA, docker compose refuses to start, with a message asking for it. The env.example suggests ./data/mongo, in the installation folder: that is fine for a pilot. In production, put the database on a dedicated disk, as described below. Redis can stay in a Docker volume: it only holds the cache and the sessions, which rebuild themselves (if lost, they only log users out).

Mounting a dedicated disk for the database​

The following commands run on the server, as administrator, before the first startup of FoxPlan. They assume an additional empty disk, on SSD; a storage array volume (SAN over iSCSI or Fibre Channel) is also suitable. Avoid NFS or SMB network shares: MongoDB loses performance and reliability on them.

1. Identify the disk. It appears without a partition or mount point, for example sdb:

lsblk -f

2. Format it as XFS, the file system recommended by MongoDB (xfsprogs package). This command erases the disk: double-check its name.

sudo mkfs.xfs -L foxplan-data /dev/sdb

3. Mount it permanently, by its identifier (UUID), which does not change if the order of the disks changes at reboot:

sudo mkdir -p /srv/foxplan
UUID=$(sudo blkid -s UUID -o value /dev/sdb)
echo "UUID=$UUID /srv/foxplan xfs defaults,noatime 0 2" | sudo tee -a /etc/fstab
sudo mount -a
findmnt /srv/foxplan # must show /dev/sdb and xfs

4. Make Docker wait for the disk to be mounted. Without this precaution, if the disk is not mounted when the server starts, /srv/foxplan/mongo is just an empty folder on the system disk: MongoDB starts an empty database there, and FoxPlan appears to have lost everything.

sudo mkdir -p /etc/systemd/system/docker.service.d
printf '[Unit]\nRequiresMountsFor=/srv/foxplan\n' | sudo tee /etc/systemd/system/docker.service.d/foxplan-data.conf
sudo systemctl daemon-reload

Docker then does not start as long as /srv/foxplan is not mounted.

5. Set the directories in .env:

FOXPLAN_MONGO_DATA=/srv/foxplan/mongo
FOXPLAN_GARAGE_DATA=/srv/foxplan/garage # only if you add Garage

6. Prepare and start. ./install.sh creates the directories (with meta and data for Garage); the MongoDB image sets their permissions itself.

./install.sh
docker compose up -d

7. Check that the database really writes to the dedicated disk:

docker inspect foxplan-mongo --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'
# /srv/foxplan/mongo -> /data/db
sudo ls /srv/foxplan/mongo # WiredTiger* files, collection-*…
df -h /srv/foxplan # free space on the data disk

Then monitor free space: MongoDB stops dead when it runs out of space, and the application with it.

Existing installation: moving the database out of a Docker volume​

Earlier installations kept the database in the mongo_data Docker volume. After updating the docker-compose.yml, set FOXPLAN_MONGO_DATA then run ./install.sh: if it finds the directory empty while a database exists in the old volume, it stops and displays the copy command to run. In detail:

docker compose stop product-server mongo
mkdir -p /srv/foxplan/mongo
docker run --rm -v YOUR_PROJECT_mongo_data:/from:ro -v /srv/foxplan/mongo:/to alpine \
sh -c 'cp -a /from/. /to/'
./install.sh
docker compose up -d

The exact volume name can be read with docker volume ls | grep mongo_data. Keep the old volume until you have checked the application, then delete it (docker volume rm). Same principle for Garage: copy garage_meta into $FOXPLAN_GARAGE_DATA/meta and garage_data into $FOXPLAN_GARAGE_DATA/data.

Installation​

1. Preparation​

chmod +x install.sh
./install.sh

On the first run, the script creates .env from env.example and stops so you can fill it in.

VariableDescription
DOMAINDomain served, for example foxplan.my-company.com
FOXPLAN_REGISTRYImage registry address, communicated by FoxPlan
FOXPLAN_IMAGEBase name of the images — already set: foxplan_prod
FOXPLAN_VERSIONDeployed version — pin a specific version in production
MONGO_IMAGE / REDIS_IMAGEValidated versions of the dependencies
FOXPLAN_MONGO_DATADirectory of the database on the host — preferably a dedicated disk, see Volumes and disks
TLS_MODEselfsigned, acme or static — see TLS
ACME_EMAIL, ACME_CA_SERVERCertificate authority, if TLS_MODE=acme
latest in production

FOXPLAN_VERSION=latest follows the most recent published version: handy for a pilot, not recommended afterwards. A simple container restart can then change the version without you deciding it. Pin a version, and perform your upgrades explicitly.

Then run ./install.sh again: it generates the default certificate and the Traefik configuration matching your TLS_MODE. The script is idempotent — you can run it again at will, it never replaces an existing certificate or .env.

2. Application secrets​

Three files, to create from the templates downloaded above:

cp env_application.example .env_application # database, emails, S3, JWT…
cp env_mongo.example .env_mongo # MongoDB credentials
cp env_redis.example .env_redis # Redis password

SECRET_KEY_JWT is generated once and for all:

openssl rand -base64 64 | tr -d '\n'

Consistency rules to respect, or startup fails:

  • URL_MONGO reuses the user and password from .env_mongo, with the host mongo;
  • SPRING_REDIS_PASSWORD is identical to REDIS_PASSWORD;
  • BASE_URL is https:// followed by DOMAIN — it builds the links contained in emails.
Two files, two roles

.env is read by Docker Compose to assemble the stack; it is not passed to the application container. Application variables — database, emails, S3, JWT — go into .env_application. An application variable placed in .env silently has no effect.

Sending emails​

FoxPlan sends invitations, password resets and notifications. Without configuration, no email is sent: the application starts and works, but these features stay silent.

To fill in .env_application:

MAIL_PROXY_TYPE=smtp
MAIL_FROM=foxplan@mon-entreprise.fr

MAIL_SMTP_HOST=smtp.mon-entreprise.fr
MAIL_SMTP_PORT=587
MAIL_SMTP_USERNAME=
MAIL_SMTP_PASSWORD=
MAIL_SMTP_AUTH=true
MAIL_SMTP_STARTTLS=true
Check sending from the application

The Settings → Company → Emails tab shows, email by email, what your sending server replied, with a green / orange / red banner on the platform status. It is the first place to look when a user says they received nothing — see Email log.

MAIL_PROXY_TYPE also accepts sendgrid or gmail, for an account you provide; MAIL_PROXY_PARAMS then holds <sender>;<key or password>. In the vast majority of installations, smtp is the right choice.

Two points decide the fate of your messages:

  • MAIL_FROM must be authorized to send for your domain (SPF, DKIM). A misaligned sender ends up in spam, or is rejected without the application knowing anything about it.
  • BASE_URL builds the links in emails. If it is missing or wrong, messages are sent correctly but the links they contain — invitation, reset — lead nowhere.

To check after startup, trigger a password reset from the login screen and follow the logs:

docker compose logs -f product-server | grep -i mail

A sending failure appears there as a warning: the application never stops because of an email that was not sent.

3. Startup​

./install.sh --start

The script first checks that all the images of the stack are present on the server. If one is missing, it names it and stops without starting anything: download it (step 1), then run it again. Otherwise, it starts the stack with docker compose up -d --pull never, without contacting any registry.

Verification:

docker compose ps
curl -I https://YOUR_DOMAIN/
curl -I https://YOUR_DOMAIN/docs/
Check the installation from the application

Once logged in as an administrator, the Settings → FoxPlan account → Diagnostics tab tests the database, Redis, email delivery (a test message is sent to you), S3 storage and the export service — see FoxPlan account.

4. Create the first account​

Open https://YOUR_DOMAIN: as long as no instance exists, a diagnostic page opens (First setup screen) and shows you whether everything works correctly — see what is checked. If everything responds, click Create the first instance with my account. FoxPlan sends a sign-up code to the address entered, through the SMTP server configured in .env_application: it arrives in the recipient's real inbox. Enter this code, follow the wizard (profile, company, modules, invitations), then choose your password.

This first account is the administrator of the instance: use an address belonging to the team that will run FoxPlan, not a temporary personal account.

Configure email delivery before creating the account

Without a working SMTP server, the sign-up code is not sent and no account can be created. This is why the first-setup screen only offers account creation once the sending server has been reached. Check the Sending emails section, then follow the logs during sign-up: docker compose logs -f product-server | grep -i mail.

5. Check the installation: diagnostics​

The diagnostics check, for the trial as for the enterprise installation:

ItemCheck
ApplicationThe FoxPlan server responds
DatabaseMongoDB responds
CacheRedis responds
Email sending serverThe SMTP server is reachable (no email is sent)
Attachment storageThe S3 bucket is accessible — "not configured" if there is none
Schedule exportThe gantt-export service responds, tested from your browser
DocumentationThe docs service responds, tested from your browser

They can be opened in two ways:

  • On first launch, as long as no instance has been created: the First setup screen runs them on its own, then offers to create the first instance if no item is in error. A "not configured" item, such as storage in the trial, does not block.
  • Afterwards, at any time: the diagnostics icon, to the right of the language selector on the sign-in page. It only appears on an on-premise installation, and works without being signed in — precisely when nobody manages to sign in.

Without signing in, the diagnostics only show statuses, with no server name or technical message: the sign-in page is visible to anyone who reaches it. The detail of each error, and a real test email, are in the full diagnostics, once signed in: Settings → FoxPlan account → Diagnostics — see FoxPlan account.

File storage​

FoxPlan stores its files in an S3-compatible object storage: project documents, client contract attachments, profile photos. The files remain private: the application reads them back and serves them itself, after checking access rights. The storage therefore does not need to be reachable from the Internet, nor to manage public permissions.

Variable (.env_application)Role
FILE_S3_URLAddress of the S3 service, for example http://garage:3900
FILE_S3_BUCKETBucket for the files
FILE_S3_ACCESS_KEY, FILE_S3_SECRET_KEYCredentials for the bucket
FILE_S3_REGIONRegion signed into each request — the one your storage expects. Default: fr-par

Without storage, leave these variables empty: the application works, only the files are unavailable. A file cannot exceed 10 MB. FoxPlan applies no quota on premise: the space is yours.

Your existing storage​

Any S3-compatible storage will do: Ceph, NetApp StorageGRID, Scality, MinIO, AWS… Create a bucket and credentials dedicated to FoxPlan, with read, write and delete rights on that bucket, then fill in the variables above.

You already have S3 storage: do not modify docker-compose.yml

The enterprise installation does not include Garage: it connects to the storage you designate. Leave docker-compose.yml as it is and do not create a docker-compose.override.yml; the whole configuration goes into .env_application:

# .env_application — your S3 storage
FILE_S3_URL=https://s3.my-company.local # address of the S3 service, without the bucket name
FILE_S3_BUCKET=foxplan # bucket created for FoxPlan
FILE_S3_ACCESS_KEY=<access key ID>
FILE_S3_SECRET_KEY=<secret key>
FILE_S3_REGION=<region expected by the storage> # e.g. us-east-1, eu-west-3, or that of your Ceph/MinIO

Then apply the configuration: docker compose up -d product-server (the container is recreated with the new values).

  • The Docker server must reach the FILE_S3_URL address (DNS, firewall): the application reads and writes the files, never the browser.
  • Over https, the storage certificate must be signed by a recognised authority; an unrecognised internal certificate makes the connection fail.
  • The application addresses the bucket in the path (https://s3.my-company.local/foxplan/…): no per-bucket DNS is needed.
  • Check the result in the diagnostics: the Storage line must be green.
The region must be the storage's

The region is part of the signature of every request. If FILE_S3_REGION differs from the one the storage expects, all requests are refused (error 400 in the logs). The diagnostics report it: storage in error.

Garage, if you don't have one​

Garage is a lightweight, open-source S3 storage developed in France. The local trial already includes it. To add it to the enterprise installation without touching docker-compose.yml, create next to it a docker-compose.override.yml file, which Docker Compose reads automatically:

services:
garage:
image: dxflrs/garage:v2.1.0
restart: always
volumes:
- ./garage.toml:/etc/garage.toml:ro
# Host directories (FOXPLAN_GARAGE_DATA in .env): they survive "docker compose down -v".
- ${FOXPLAN_GARAGE_DATA:?Set FOXPLAN_GARAGE_DATA in .env}/meta:/var/lib/garage/meta
- ${FOXPLAN_GARAGE_DATA:?Set FOXPLAN_GARAGE_DATA in .env}/data:/var/lib/garage/data
networks:
- foxplan-network
deploy:
resources:
limits:
memory: 1g

Set the files directory in .env, preferably on the data disk (see Volumes and disks), then run ./install.sh again, which creates its meta and data subfolders:

FOXPLAN_GARAGE_DATA=/srv/foxplan/garage

Then create the garage.toml file, with two secrets generated for your installation:

RPC_SECRET=$(openssl rand -hex 32)
ADMIN_TOKEN=$(openssl rand -base64 32)
printf '%s\n' \
'metadata_dir = "/var/lib/garage/meta"' \
'data_dir = "/var/lib/garage/data"' \
'db_engine = "sqlite"' \
'replication_factor = 1' \
'rpc_bind_addr = "[::]:3901"' \
'rpc_public_addr = "127.0.0.1:3901"' \
"rpc_secret = \"$RPC_SECRET\"" \
'' \
'[s3_api]' \
's3_region = "garage"' \
'api_bind_addr = "[::]:3900"' \
'' \
'[admin]' \
'api_bind_addr = "[::]:3903"' \
"admin_token = \"$ADMIN_TOKEN\"" > garage.toml
chmod 600 garage.toml

Start Garage, then prepare it — topology, bucket and access key:

docker compose up -d garage
docker compose exec garage /garage status # note the node ID (first column)
docker compose exec garage /garage layout assign -z dc1 -c 100G <NODE_ID>
docker compose exec garage /garage layout apply --version 1
docker compose exec garage /garage bucket create foxplan
docker compose exec garage /garage key create foxplan # displays "Key ID" and "Secret key"
docker compose exec garage /garage bucket allow --read --write --owner foxplan --key foxplan

-c 100G is the announced capacity of the node: adjust it to the disk space reserved for files. Finally, copy the key into .env_application:

FILE_S3_URL=http://garage:3900
FILE_S3_BUCKET=foxplan
FILE_S3_ACCESS_KEY=<Key ID>
FILE_S3_SECRET_KEY=<Secret key>
FILE_S3_REGION=garage

then restart the application:

docker compose up -d product-server

The diagnostics (icon on the sign-in page, or FoxPlan account → Diagnostics) should show the storage as OK. Garage is only reachable from the compose internal network: no port is published.

A value made of digits goes in quotes

In a YAML file, a value made only of digits is read as a number: a key 0000…01 would become 1. If you place a key in a docker-compose file, put it in quotes. In .env_application, the problem does not arise.

Inbound and outbound certificates​

FoxPlan uses two different certificates, one per traffic direction. They go neither in the same place nor in the same component.

INBOUND flows browser ──HTTPS──► Traefik ──► application
Traefik presents FOXPLAN'S certificate → traefik/certs/

OUTBOUND flows application ──HTTPS, SMTP TLS──► internal AD FS, SMTP, S3…
the application checks THEIR certificate using → ca-sortant/
the authority that signed them
Inbound flowsOutbound flows
PurposeProve to browsers that the server really is DOMAINLet the application recognise your internal services
Used byTraefikThe application (Java)
ContentThe FoxPlan server certificate and its private keyThe public certificate of the authority — root, plus intermediates if any. Never a private key
Locationtraefik/certs/tls.crt and traefik/certs/tls.keyca-sortant/<name>.crt — one or more files; another directory can be set with FOXPLAN_OUTBOUND_CA_DIR in .env
FormatPEM. tls.crt: server certificate followed by its intermediates. tls.key: unencrypted key, no passphrasePEM, .crt extension required. One file may hold several certificates
RequiredYes — install.sh generates a self-signed one until yours is in placeOnly if a called service is signed by a non-public authority
Taken into accountOn the fly, after touch traefik/dynamic/tls.yml — see the static modeAt startup: ./install.sh (checks the files), then docker compose restart product-server

A PEM file is a text file that only contains -----BEGIN CERTIFICATE----- … -----END CERTIFICATE----- blocks (and, for the key, -----BEGIN PRIVATE KEY-----).

Is it the same certificate?​

No. The inbound certificate is FoxPlan's own, together with its private key. The outbound certificate is that of the authority that signed the certificates of AD FS, SMTP or S3: public, with no key.

They do, however, often come from the same PKI. If the internal authority signed both FoxPlan's certificate and AD FS's, its root authority certificate is used:

  • in ca-sortant/, so that the application recognises AD FS, SMTP…;
  • in traefik/ca/, only in acme mode with an internal PKI, so that Traefik recognises the ACME server.

Never copy tls.crt or tls.key into ca-sortant/: install.sh rejects any file containing a private key.

Is an outbound authority needed?​

From the FoxPlan server, display the issuer of each internal service's certificate:

# AD FS, S3, APIs: HTTPS
echo | openssl s_client -connect adfs.my-company.local:443 -servername adfs.my-company.local 2>/dev/null \
| openssl x509 -noout -issuer
# SMTP with STARTTLS
echo | openssl s_client -connect smtp.my-company.local:587 -starttls smtp 2>/dev/null \
| openssl x509 -noout -issuer

Public issuer (DigiCert, Sectigo, GlobalSign, Let's Encrypt…): nothing to do. Internal issuer: ask the PKI team for the root authority certificate and its intermediates, in PEM format, and drop it into ca-sortant/.

If it is missing, SSO sign-in fails on return from the provider, or emails are not sent, with PKIX path building failed in docker compose logs product-server.

Converting to PEM​

File receivedCommand
Binary .cer or .deropenssl x509 -inform DER -in authority.cer -out ca-sortant/authority.crt
.p7b (authority chain)openssl pkcs7 -print_certs -in chain.p7b | sed -n '/-----BEGIN/,/-----END/p' > ca-sortant/authority.crt
.pfx or .p12 (FoxPlan certificate with its key)openssl pkcs12 -in foxplan.pfx -nokeys | sed -n '/-----BEGIN/,/-----END/p' > traefik/certs/tls.crt
openssl pkcs12 -in foxplan.pfx -nocerts -nodes | sed -n '/-----BEGIN/,/-----END/p' > traefik/certs/tls.key

The sed removes the descriptive lines (Bag Attributes, subject=…) that openssl adds. In ca-sortant/, they would prevent the application from starting: install.sh detects them and stops, giving the clean-up command. With a .pfx, check that tls.crt starts with the server certificate, before its intermediates.

TLS: three modes​

Whatever the mode, install.sh installs a default certificate. Traefik serves it from the first startup, and keeps serving it if automatic issuance fails: the site never goes down for lack of a certificate; at worst it degrades to a browser warning.

The choice is made with the TLS_MODE variable, and comes down to a single question: who issues your certificates, and can Traefik talk to it?

TLS_MODEAuthorityOutbound accessRenewalUse
selfsignedNoneNone—Evaluation, pilot
acmeLet's Encrypt or your PKITo the authorityAutomaticProduction
staticAnyNoneYour responsibilityClosed network, corporate wildcard

selfsigned — start immediately​

Default mode. install.sh generates a self-signed certificate for your DOMAIN, valid for 825 days. The application is reachable over HTTPS right away, without public DNS or firewall opening.

Browsers will display a warning: this is expected. This mode is for validating the installation, not for exposing the service to users.

acme — automatic renewal​

TLS_MODE=acme
ACME_EMAIL=admin@mon-entreprise.fr
ACME_CA_SERVER=https://acme-v02.api.letsencrypt.org/directory

Traefik obtains the certificate through an HTTP-01 challenge and renews it on its own. With Let's Encrypt, two conditions apply: the domain must resolve publicly to this server, and port 80 must be reachable from the Internet — the challenge goes through it, even if all traffic is then redirected to 443.

With your internal PKI, nothing changes but the address. If your authority exposes an ACME service — step-ca, HashiCorp Vault, EJBCA — the protocol is the same (RFC 8555):

ACME_CA_SERVER=https://ca.interne.mon-entreprise.fr/acme/acme/directory

If this authority is private, Traefik does not know it and the negotiation would fail on an unknown certificate. Give it the chain of trust:

cp /path/to/ca-interne.crt traefik/ca/ca-interne.crt

then, in .env:

LEGO_CA_CERTIFICATES=/ca/ca-interne.crt

install.sh checks that the file is present and warns you if it is missing.

This is the preferred mode on a closed network: you keep automatic renewal with no dependency on the Internet, provided only that the server can reach your authority.

Short-lived certificates

Internal PKIs often issue certificates valid for a few days. This has no consequence: Traefik simply renews more often.

static — your certificate, your renewal​

For a certificate obtained offline — corporate wildcard, commercial certificate, internal authority without an ACME service. No ACME is configured: Traefik never goes out on the network to obtain a certificate.

Simply overwrite the generated files with your own:

cp your-certificate.crt traefik/certs/tls.crt # full chain
cp your-key.key traefik/certs/tls.key
chmod 600 traefik/certs/tls.key
Full chain

tls.crt must contain the server certificate followed by its intermediates, in that order. A certificate served without its chain works in a desktop browser — which often completes it on its own — but fails on mobile, on the command line and for API calls. A classic, puzzling symptom: "it works on my machine".

Renewal is your responsibility. Traefik does not watch the certificate files, only its configuration in traefik/dynamic/: after replacing both files, touch that configuration so that it reloads them on the fly, with no restart and no downtime.

touch traefik/dynamic/tls.yml

Otherwise, after the replacement Traefik keeps serving the old certificate until its next restart — or until it expires. If your PKI drops renewed certificates into a directory, the task that copies them into traefik/certs/ must end with this touch.

Changing mode​

Change TLS_MODE in .env, run ./install.sh again, then:

docker compose up -d traefik

The existing certificate is kept: to start again from a fresh certificate, delete traefik/certs/ before running the script again.

Check the served certificate​

echo | openssl s_client -connect YOUR_DOMAIN:443 -servername YOUR_DOMAIN 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates

If the issuer is your own domain when you expected an authority, the default certificate is being served: issuance failed. In order — DNS (dig +short YOUR_DOMAIN), open ports, then docker compose logs traefik | grep -iE 'acme|certificate|error'.

Routine operations​

docker compose ps # services status
docker compose logs -f product-server # application logs
./install.sh --start # upgrade, after step 1
docker compose down # stop, data kept

The MongoDB database and the Garage files live in host directories (FOXPLAN_MONGO_DATA, FOXPLAN_GARAGE_DATA, see Volumes and disks): they survive stops, upgrades and docker compose down -v. An upgrade is also done in two steps: download the images of the new version (step 1), change FOXPLAN_VERSION in .env, then run ./install.sh --start again.

Backups

A data directory is not a backup: it protects neither against a disk failure nor against an accidental deletion. See Backup and restore.

Backup and restore​

What to back up​

ItemBack upWhy
MongoDBYesAll business data: projects, tasks, budgets, users
.env* filesYesContain keys without which restored data is unusable
RedisNoCache and sessions: rebuilds itself, at the cost of users logging in again
FilesYes, at the same time as the databaseDocuments, attachments, profile photos: in the S3 storage (FILE_S3_*), not in MongoDB. With Garage, see Files stored in Garage
Back up .env_application with the database

By default, SECRET_KEY_JWT is also the encryption key for the SSO secrets stored in the database. Restoring a MongoDB dump without this key makes the SSO configurations undecryptable: they are there, unreadable, and must be entered again in full. A database backup without the secrets is not a complete backup.

Backup​

The command runs inside the MongoDB container, which ships the necessary tools:

docker compose exec -T mongo sh -c \
'mongodump --username "$MONGO_INITDB_ROOT_USERNAME" \
--password "$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase admin --archive --gzip' \
> foxplan-$(date +%F-%H%M).archive.gz

Three details that matter:

  • Credentials are read from the container's environment, never written on the command line — so they appear neither in ps nor in the shell history. A password containing spaces or special characters thus goes through without escaping.
  • --archive produces a single file rather than a directory tree: easier to transfer, encrypt and verify.
  • -T disables pseudo-terminal allocation, otherwise the binary stream would be corrupted by the redirection.

The backup is done hot, without stopping the application.

Restore​

docker compose exec -T mongo sh -c \
'mongorestore --username "$MONGO_INITDB_ROOT_USERNAME" \
--password "$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase admin --archive --gzip --drop' \
< foxplan-2026-09-15-0300.archive.gz

--drop replaces each collection with the backed-up version. Without this option, the restore would merge with the existing data, resulting in an inconsistent state — rarely what you want.

Flush the Redis cache after any restore

Redis keeps objects that reference identifiers from the old database. After a restore, these references no longer match anything and the application returns puzzling 500 errors — typically an object "not found" even though it is present in the database.

docker compose exec redis sh -c 'redis-cli -a "$REDIS_PASSWORD" FLUSHALL'
docker compose restart product-server

This is the most frequently forgotten step after a restore.

Files stored in Garage​

MongoDB references files by their key: back up the database and the files at the same time, so you can restore them together.

Hot, file by file — the preferred method. rclone copies the bucket's content into a directory without stopping Garage; subsequent runs only transfer what has changed. With the credentials from .env_application:

NET=$(docker network ls --format '{{.Name}}' | grep foxplan-network)
KEY=$(grep ^FILE_S3_ACCESS_KEY= .env_application | cut -d= -f2)
SECRET=$(grep ^FILE_S3_SECRET_KEY= .env_application | cut -d= -f2)
mkdir -p /var/backups/foxplan/files
docker run --rm --network "$NET" -v /var/backups/foxplan/files:/backup \
rclone/rclone:1.68.2 sync :s3:foxplan /backup \
--s3-provider Other --s3-endpoint http://garage:3900 --s3-region garage \
--s3-access-key-id "$KEY" --s3-secret-access-key "$SECRET"

To restore, swap source and destination (sync /backup :s3:foxplan), on a Garage prepared as during installation.

Cold, full directory — for a server migration. Stop Garage, archive its directory (FOXPLAN_GARAGE_DATA, with its meta and data subfolders), then restart it:

docker compose stop garage
sudo tar czf garage-$(date +%F).tar.gz -C /srv/foxplan/garage meta data
docker compose start garage

The two subfolders go together: data without its metadata is unusable. As with MongoDB, transfer the backups off the server.

Automate​

A daily cron job, with 30-day rotation:

# /etc/cron.d/foxplan-backup
0 3 * * * root cd /opt/foxplan && \
docker compose exec -T mongo sh -c 'mongodump --username "$MONGO_INITDB_ROOT_USERNAME" --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --archive --gzip' \
> /var/backups/foxplan/foxplan-$(date +\%F).archive.gz 2>/var/log/foxplan-backup.log && \
find /var/backups/foxplan -name 'foxplan-*.archive.gz' -mtime +30 -delete

The % must be escaped as \% in a cron table, otherwise the line is truncated at that point.

A local backup protects against nothing

An archive stored on the server it backs up disappears with it. Transfer it to separate storage — object storage, backup server, tape. That is half the work, and the half that gets forgotten.

Check that the backup is usable​

A backup that has never been restored is not a backup: it is a hypothesis. Test it periodically on a separate environment — never on production.

# Quick archive integrity check, without restoring anything
gzip -t foxplan-2026-09-15-0300.archive.gz && echo "archive OK"

Then, once or twice a year, run a full restore on a test server and log in to the application: it is the only check that proves anything.

Alternative: cold backup of the database​

For a bit-for-bit copy rather than a logical export, stop the database and archive its directory (FOXPLAN_MONGO_DATA):

docker compose stop product-server mongo
sudo tar czf mongo-$(date +%F).tar.gz -C /srv/foxplan/mongo .
docker compose start mongo product-server

Faster on a large database, but with two trade-offs: the application is interrupted during the operation, and the archive can only be restored on an identical MongoDB version. mongodump remains the preferred method; keep this one for server migrations.

Maintenance mode​

An activatable maintenance page, which keeps /docs accessible during the intervention, is provided with the assisted deployment package. It is not included in the downloadable files above. Contact support to obtain it.

Troubleshooting​

SymptomLead
manifest unknown at startupFOXPLAN_VERSION does not exist
pull access deniedWrong FOXPLAN_REGISTRY or FOXPLAN_IMAGE — the image is called foxplan_prod — or an old docker login refused by the registry: docker logout <registry host>
required variable FOXPLAN_REGISTRY is missingRegistry address missing from .env: set the value communicated by FoxPlan
env file .env_mongo not foundThe secret files have not been created — see Application secrets
404 on /Incorrect DOMAIN, or application not started — see the logs
502 / 503The application is still starting, or stopped on an error
Invalid certificateDNS, ports, ACME logs — see Check the served certificate
Doubt about an integrationFoxPlan account → Diagnostics: database, Redis, emails, S3 and export tested in one click
No email receivedMAIL_PROXY_TYPE or MAIL_SMTP_* missing — see Sending emails
Emails received but links invalidBASE_URL missing or different from the public domain
Emails in spamMAIL_FROM not authorized to send for the domain (SPF/DKIM)
Redis authentication errorSPRING_REDIS_PASSWORD different from REDIS_PASSWORD
Mongo authentication failureURL_MONGO inconsistent with .env_mongo
Gantt exports failinggantt-export service stopped, or MSP_SERVICE_ENDPOINT unreachable
500 "object not found" errors after a restoreRedis cache not flushed — see Restore
SSO configuration unreadable after a restoreSECRET_KEY_JWT different from the one in use at dump time
Storage in error in the diagnostics, 400 on S3 requestsFILE_S3_REGION differs from the storage's region (s3_region for Garage) — see File storage
Storage in error right after a Garage key changeThe key in .env_application is not Garage's: docker compose exec garage /garage key info foxplan --show-secret
"File too large"A file exceeds 10 MB
"Storage quota reached"SaaS only: the instance has used up its space; a license can extend it
no matching manifest for linux/arm64Export images are published for amd64 only: keep platform: linux/amd64 on gantt-export and gantt-msp (Docker Desktop emulates them)
The application restarts in a loop, Client id of registration 'google' must not be empty in the logsImage older than October 2026 with Microsoft or Google SSO lines declared but empty in .env_application: update the image, or comment out these lines
FoxPlan starts again without data after a server restartData disk not mounted: MongoDB started on the empty folder of the system disk. findmnt /srv/foxplan, then step 4 of Volumes and disks
required variable FOXPLAN_MONGO_DATA is missing a valueVariable missing from .env: see Volumes and disks
MongoDB empty after setting FOXPLAN_MONGO_DATAData left in the old Docker volume: see Existing installation
The application restarts in a loop, https://${FILE_S3_URL} in the logsFILE_S3_* variables missing from .env_application: declare them, even empty — attachments will then be unavailable
PKIX path building failed in the application logsInternal authority of AD FS, SMTP or S3 missing from ca-sortant/: see Inbound and outbound certificates