Getting Started
Production Deployment
Run Who's OOO in production with docker-compose.prod.yml: configuration, TLS proxy, first admin, workers, backups, and upgrades
For production, Who's OOO ships a separate Compose file, docker-compose.prod.yml. It builds the prod image target, mounts no source code, sets APP_ENV=prod, and starts two Messenger workers next to PHP-FPM, nginx, and MySQL. The PHP container compiles the asset map on every start, so there is no separate asset build step.
The stack serves plain HTTP on port 80. TLS is the job of your reverse proxy (see step 2).
Most of this is standard Symfony and Docker. Two parts can cost you data or access if you skip them: the first section below, and creating the first admin account.
Keep the development stack away from production data
Warning: The default
docker-compose.ymlis the development stack. Its entrypoint drops the database and reloads demo fixtures every time the container starts. Against production data, onedocker compose upwith that file wipes the database, and there is no undo.
You need both of these safeguards:
- Keep
COMPOSE_FILE=docker-compose.prod.ymlin the root.env. It ships in.env.distand pins every baredocker compose …command in the checkout to the production stack, so a plaindocker compose up -dis safe. - Never pass
-f docker-compose.ymlin a production checkout, and never rundocker composefrom a directory without the root.env. TheCOMPOSE_FILEpin does nothing in either case.
The two stacks also use separate Docker volumes and container names, so they can't end up sharing a database by accident. To promote an existing dev install, see Moving a dev install to production.
1. Configure
There are two files, and different programs read them.
The root .env is read by Docker Compose. It holds the database container's credentials and the COMPOSE_FILE pin. Create it with cp .env.dist .env and change the passwords.
app/.env.local is read by Symfony. Create it with at least:
APP_ENV=prod APP_SECRET=<run: openssl rand -hex 16> APP_BASE_URL=https://leave.example.com TRUSTED_PROXIES=<your reverse proxy's IP or CIDR> TRUSTED_HOSTS='^leave\.example\.com$' DATABASE_URL="mysql://ooo:<MYSQL_PASSWORD>@db:3306/ooo_db?serverVersion=8.4.4&charset=utf8mb4" MAILER_DSN=smtp://user:pass@smtp.example.com:587 EMAIL_FROM_ADDRESS=noreply@example.com EMAIL_FROM_NAME="Who's OOO" TOTP_ENCRYPTION_KEY=<run: openssl rand -base64 32> ICAL_SECRET=<run: openssl rand -hex 16> MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
- The user, password, and database name in
DATABASE_URLmust match theMYSQL_*values in the root.env. APP_BASE_URLtakes no trailing slash. Emails sent from the workers and from scheduled jobs run outside any HTTP request, so they build their links from this value.ICAL_SECRETmust be at least 32 characters.openssl rand -hex 16gives exactly 32, and the app logs a warning in production when the secret is shorter.
Warning: Don't start from a copy of
app/.env. That file setsAPP_ENV=devand pointsMAILER_DSNat Mailpit.
Slack variables are optional. See Configuration for the full list of environment variables.
2. Put a TLS proxy in front
The app doesn't terminate TLS. Put nginx, Caddy, Traefik, or a cloud load balancer in front of it and let that proxy handle HTTPS.
Once the proxy is in place, set TRUSTED_PROXIES in app/.env.local to the address the proxy connects from. Without it, Symfony ignores the X-Forwarded-* headers and treats every request as plain http: session cookies lose the Secure flag, and absolute URLs built during a request start with http://.
# One or more comma-separated IPs or CIDRs, covering only the proxy itself. # 172.18.0.1 is an example: use your proxy's address or your compose network gateway. TRUSTED_PROXIES=172.18.0.1
Avoid REMOTE_ADDR and PRIVATE_SUBNETS unless port 80 is reachable by the proxy and nothing else. If you trust any address other than your proxy, clients can forge their IP, scheme, and host.
docker-compose.prod.yml publishes port 80 on every interface (IPv4 and IPv6) by default. If the proxy runs on the same host, set HTTP_BIND_ADDRESS=127.0.0.1 in the root .env.
Set TRUSTED_HOSTS to a regular expression that matches your public host name. Symfony then answers 400 to any request with a different Host header. When the variable is empty (the default), every host is accepted.
TRUSTED_HOSTS='^leave\.example\.com$'
TRUSTED_HOSTS doesn't replace APP_BASE_URL. You need both.
3. Start
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml exec php bin/console doctrine:migrations:migrate --no-interaction
The workers wait for the messenger_messages table before they start consuming, so they sit idle until the migrations have run.
4. Create the first admin account
Production never loads fixtures, and new accounts are created by invitation only. A fresh install therefore has nobody who can log in until you insert an admin row by hand. After that, admins add everyone else from the UI (see User Invitations).
Warning: The very first migration inserts an inactive admin,
admin@whoisooo.app, into every install. Its password hash is public in the repository. Never reactivate it. Delete it with the statement below. If MySQL refuses because other rows reference the account, leave it inactive.
DELETE FROM user WHERE email = 'admin@whoisooo.app' AND is_active = 0;
Generate an Argon2id hash for the password you want:
docker compose -f docker-compose.prod.yml exec php \
bin/console security:hash-password 'your-password' 'App\Infrastructure\Doctrine\Entity\User'
Copy the Password hash value. Then open a MySQL shell inside the container and paste the statement below there:
docker compose -f docker-compose.prod.yml exec db mysql -u root -p ooo_db
INSERT INTO user (
id, first_name, last_name, email, password, roles,
annual_leave_allowance, current_leave_balance,
is_active, is_email_notifications_enabled, celebrate_work_anniversary,
working_days, backup_codes, is_two_factor_enabled,
absence_balance_reset_day, theme_preference, palette_preference,
created_at, updated_at
) VALUES (
UUID(), 'Ada', 'Lovelace', 'admin@example.com',
'$argon2id$v=19$m=65536,t=4,p=1$REPLACE$WITH_THE_HASH_FROM_ABOVE',
'["ROLE_ADMIN"]',
30, 30,
1, 1, 1,
'[1, 2, 3, 4, 5]', '[]', 0,
MAKEDATE(YEAR(CURRENT_DATE()), 1), 'auto', 'teal',
NOW(), NOW()
);
Warning: Don't put the hash into a command on your host shell. It contains
$characters, and the host shell will expand them and corrupt the hash without any error.
About the values:
- Every listed column is
NOT NULL. The omitted columns (profile_image_url,birth_date,contract_started_at,manager_id,holiday_calendar_id,totp_secret,subdivision_code, and others) are nullable and can be filled in later from the profile page. rolesandworking_daysare JSON columns, so keep the quoting exactly as shown.ROLE_USERis added at runtime, so["ROLE_ADMIN"]is enough.is_activemust be1. Inactive users can't log in and are hidden from team lists and calendars.working_dayslists ISO weekday numbers (1is Monday).absence_balance_reset_dayis the date the yearly leave balance resets. Most installs use 1 January of the current year.
Log in at https://your-domain/login, change the password, and turn on two-factor authentication from Security in the Account section of the sidebar.
Tip: A
bin/console app:user:create-admincommand is planned to replace this manual step. Until it exists, the SQL above is the supported path.
Background workers
Emails, auto-approvals, and every scheduled job (including Slack status sync and the weekly digest) go through Symfony Messenger. If nothing consumes the queue, no email goes out and the messages pile up in the messenger_messages table. Two services consume it:
| Service | Transport | What stops working without it |
|---|---|---|
worker-async |
async |
Invitation emails, leave request notification emails, auto-approval messages |
worker-scheduler |
scheduler_default |
Leave request auto-approve (5 min), Slack status sync (20 min), app:feed:sync for the in-app "What's new" feed (6 h), password reset token cleanup and leave balance reset (daily), public holiday calendar sync (yearly) |
worker-scheduler |
scheduler_weekly_digest |
The Slack weekly digest |
They are split because the scheduler must not restart on a timer. Schedules keep no state, so a restarted scheduler computes the next run from the current time and can skip a job that fell due while it was down. worker-async restarts itself every hour (WORKER_TIME_LIMIT); worker-scheduler doesn't.
Check that both are running:
docker compose -f docker-compose.prod.yml logs worker-async worker-scheduler
A messenger_messages table that keeps growing means a worker has stopped. Messages that fail repeatedly move to the failed queue instead of being thrown away:
docker compose -f docker-compose.prod.yml exec php bin/console messenger:failed:show docker compose -f docker-compose.prod.yml exec php bin/console messenger:failed:retry
Test your mail configuration
Symfony's mailer:test command sends from from@example.org unless told otherwise, and many SMTP providers reject that address. Pass your own sender:
docker compose -f docker-compose.prod.yml exec php bin/console mailer:test you@example.com --from noreply@example.com
Application settings storage
App Settings are stored in a YAML file on the whoisooo-prod_settings volume, at /var/www/settings/app_setting.yaml. The php container and both workers mount the same volume, so a change saved in the UI reaches the scheduled jobs on their next run and survives up --build.
On first start, the containers copy the defaults from the image onto the empty volume. After that, the file on the volume wins and later images never overwrite it.
docker-compose.prod.yml sets APP_SETTINGS_FILE for these containers, which overrides any value in app/.env.local. If you kept your own settings file somewhere else, copy it onto the volume once:
docker compose -f docker-compose.prod.yml cp my-settings.yaml php:/var/www/settings/app_setting.yaml docker compose -f docker-compose.prod.yml exec php chown www-data:www-data /var/www/settings/app_setting.yaml
Upgrading an existing install
Deactivated users can't log in, and an active session ends on the user's next request after deactivation. The is_active column was added without a backfill, so on older installs some people may still have it set to 0. Before you upgrade, run this read-only query to list the accounts that will be locked out:
SELECT u.email, u.created_at FROM user u LEFT JOIN invitation i ON i.user_id = u.id WHERE u.is_active = 0 AND i.id IS NULL;
Reactivate the people on that list who should keep access. Anyone you miss can't log in after the upgrade until an admin reactivates them.
Warning: Don't fix this with a blanket
UPDATE user SET is_active = 1, because that also reactivates accounts that were deactivated on purpose. If the list includesadmin@whoisooo.app, delete it as described in Create the first admin account.
Backups
Nothing is backed up for you. Application data lives in the whoisooo-prod_mysql_prod volume, uploaded profile images in whoisooo-prod_uploads, and App Settings in whoisooo-prod_settings.
Database:
docker compose -f docker-compose.prod.yml exec -T db \
sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" ooo_db' \
| gzip > backup-$(date +%F).sql.gz
Keep the single quotes around the sh -c argument. $MYSQL_ROOT_PASSWORD is only set inside the container, so it has to expand there. Without the wrapper, your host shell expands it first, the password comes out empty, and the dump fails.
Profile images:
docker run --rm -v whoisooo-prod_uploads:/data -v "$PWD":/backup alpine \
tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
App Settings:
docker compose -f docker-compose.prod.yml cp php:/var/www/settings/app_setting.yaml app_setting-$(date +%F).yaml
Moving a dev install to production
The development and production stacks use different Docker volumes (who-is-out-of-office_mysql and whoisooo-prod_mysql_prod) and different container names. If you switch a dev install to docker-compose.prod.yml, it starts with an empty database. You can't point the production stack at the dev volume either, because MySQL won't apply new MYSQL_* credentials to a data directory that is already initialised. Move the data with a dump and restore.
Warning: These are the only commands on this page that use
-f docker-compose.yml. Starting the devphpcontainer drops the database, so if the dev stack is already stopped, start only itsdbservice (docker compose -f docker-compose.yml up -d db) before you dump.
# 1. Dump the dev database (it has no root password), then stop the dev stack.
# Both stacks publish port 80, so they cannot run side by side.
docker compose -f docker-compose.yml exec -T db \
sh -c 'exec mysqldump -u root ooo_db' > dev-dump.sql
docker compose -f docker-compose.yml stop
# 2. Start only the production database and restore the dump into it
docker compose -f docker-compose.prod.yml up -d --wait db
docker compose -f docker-compose.prod.yml exec -T db \
sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" ooo_db' < dev-dump.sql
# 3. Start the rest of the stack and bring the schema up to date
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml exec php bin/console doctrine:migrations:migrate --no-interaction
Restore before you migrate. The dump drops and recreates every table, including doctrine_migration_versions, so migrations run first would be undone by the restore. Restoring before the workers start also keeps them from consuming queued dev messages.
Warning: The dump contains the dev fixture accounts, including
admin@whoisooo.app, and every fixture user has the password123. Delete those accounts or change their passwords before the site is reachable.
In the dev stack, profile images sit in app/public/uploads on the host, not in a volume. Copy them into the production volume with:
docker run --rm -v whoisooo-prod_uploads:/data -v "$PWD/app/public/uploads":/src:ro alpine \
cp -a /src/. /data/