Installation
This page walks through bringing IT Ticket System up from scratch: the development environment, environment variables, production deployment, the Coolify + Nginx Proxy Manager topology, database migrations, and fixes for the most common problems. For the reasoning behind the architecture see the Architecture page, and for the security checklist see the Security page.
Requirements
- Docker and Docker Compose (v2, the
docker composecommand). - Nothing else is needed — Node.js, PostgreSQL, and Redis run inside containers.
opensslfor generating secrets (ships by default on macOS/Linux).
Development environment
git clone https://github.com/mahmutyum/ticket-system.git
cd ticket-system
cp .env.example .envFill in the changeme_* values in .env (see the Environment variables section), then:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildThis command:
- Brings up Postgres and Redis and waits for them to become healthy.
- Applies the database schema (
prisma migrate deploy). - Starts the backend with
tsx watch(hot reload) and the frontend withvite. - Sets
NODE_ENVtodevelopment: readable logs, verbose error detail.
The source code is bind-mounted — changes you make under backend/src or frontend/src are reflected without restarting the container.
Load the sample data (3 companies, locations, categories, staff, templates):
docker compose exec backend npx tsx prisma/seed.tsTo evaluate the tickets, tasks, reports, onsite support, and password vault screens with populated data, then run the synthetic detail seed:
docker compose exec backend npm run db:seed:demoThe detail seed is re-runnable and manages only the fictional records explicitly tagged DEMO. It contains no real system, personal, or password data, and refuses to run when NODE_ENV=production.
| Address | What |
|---|---|
| http://localhost:1111 | Interface (public portal + staff panel) |
| http://localhost:1111/staff/login | Staff login |
| http://localhost:4000/docs | API endpoint list (Swagger UI) |
| http://localhost:4000/health/live | Process liveness check |
| http://localhost:4000/health/ready | PostgreSQL + Redis readiness check |
Seed credentials: admin@company.com / admin123 · manager@company.com / staff123 · it@company.com / staff123
The seed is for demo only. Do not run prisma/seed.ts against a production database.
To stop, press Ctrl+C. To also delete the data:
docker compose down -v # -v also removes volumes: the database is resetEnvironment variables
The single source of truth is .env.example. If you use Coolify, you manage these variables from the panel — no code change required.
Required — no default; the backend will not start if left empty
| Variable | How to generate / what to enter |
|---|---|
JWT_SECRET | openssl rand -base64 48 — at least 32 characters |
JWT_REFRESH_SECRET | openssl rand -base64 48 — must be different from JWT_SECRET |
CREDENTIALS_ENC_KEY | openssl rand -hex 32 — exactly 64 hex characters |
DB_PASSWORD | Free choice. Enter the same value in DATABASE_URL. |
REDIS_PASSWORD | Free choice. Enter the same value in REDIS_URL. |
DATABASE_URL | postgresql://<DB_USER>:<DB_PASSWORD>@postgres:5432/<DB_NAME> |
REDIS_URL | redis://:<REDIS_PASSWORD>@redis:6379 |
APP_URL | The FQDN the system will be reached at. Separate multiple values with commas. |
SMTP_HOST SMTP_USER SMTP_PASS SMTP_FROM | Global SMTP. Zod requires these; a value must be provided even if you will not send email. |
About CREDENTIALS_ENC_KEY: It encrypts the entries in the password vault (/staff/passwords) with AES-256-GCM. If you lose or change this key, every password in the vault becomes permanently undecryptable. Validation checks not only the length but also the hex character set at startup. Generate the value with openssl rand -hex 32.
Why is REDIS_PASSWORD in two places? It is used both when the redis container is started with --requirepass and inside REDIS_URL. If the two differ, the backend cannot connect to Redis. The same applies to DB_PASSWORD / DATABASE_URL.
APP_URL and CORS: It takes a comma-separated list. The first value is treated as canonical and is used in the tracking links inside emails; the entire list is added to the CORS whitelist. e.g. APP_URL=https://ticket.firma.com,https://destek.firma.com
Backend variables with defaults
| Variable | Default | What it does |
|---|---|---|
NODE_ENV | development | production: JSON logs, 500 error details hidden |
PORT | 4000 | Port the backend listens on inside the container |
APP_NAME | IT Destek Sistemi | Application name |
ACCESS_TOKEN_EXPIRY | 15m | Access token lifetime |
REFRESH_TOKEN_EXPIRY | 7d | Refresh token lifetime |
SMTP_PORT | 587 | |
SMTP_SECURE | false | true means TLS (usually port 465) |
MAX_FILE_SIZE | 26214400 (25 MB) | Upper limit for file attachments |
UPLOAD_DIR | /app/uploads | In-container upload directory (bound to a volume) |
SMS_GATEWAY_URL SMS_GATEWAY_API_KEY SMS_SENDER | — | Optional. If empty, no SMS is sent. |
Variables used only by Docker Compose
| Variable | Default | What it does |
|---|---|---|
FRONTEND_PORT | 1111 | The single port exposed to the host. NPM forwards here. |
DB_NAME | ticketdb | |
DB_USER | ticket | |
BACKEND_PORT DB_PORT REDIS_PORT | 4000 5432 6379 | Exposed to the host only when docker-compose.local.yml is active |
NGINX_HTTP_PORT NGINX_HTTPS_PORT | 80 443 | Only with --profile proxy |
PGADMIN_PORT PGADMIN_EMAIL PGADMIN_PASSWORD | 5050 … | Only with --profile tools |
Production
cp .env.example .env # fill in the values
./scripts/check-production-env.sh .env
docker compose up -d --buildWhat happens:
- The
backendimage is built; on startupprisma migrate deployruns, thennode dist/server.js. - Only
frontendis exposed to the host (FRONTEND_PORT).backend,postgres, andredisare only on the internalapp-network— not exposed to the host. - The
uploadsandpgdatanamed volumes keep data persistent.
For production, .env must have:
NODE_ENV=productionAPP_URLset to your real FQDN(s)- All
changeme_*values replaced with real, randomly generated values
If you need the internal nginx proxy (when there is no NPM/Coolify):
docker compose --profile proxy up -d --buildThis profile uses nginx/conf.d/default.conf: HTTP→HTTPS redirect, TLS 1.2/1.3, rate limiting, and an IP whitelist restricted to RFC1918 private networks (external requests get a 403). Adjust server_name and the VPN subnets for your own environment. nginx/Dockerfile generates a self-signed certificate at build time — replace it with your own certificate in a real deployment.
Coolify + Nginx Proxy Manager
Internet/VPN → NPM (SSL + FQDN) → frontend:1111 ─┬─ / → SPA
├─ /api/* → backend:4000
└─ /uploads/* → backend:4000- Add the repo in Coolify —
docker-compose.ymlis detected automatically. - Enter all
.envvariables from the Coolify environment panel. - Create a proxy host in NPM:
- Domain Names:
ticket.firma.com(multiple can be entered) - Scheme:
http· Forward Hostname/IP: the Coolify host IP · Forward Port:FRONTEND_PORT(1111) - Websockets Support: ON — required for SSE (live updates)
- Block Common Exploits: ON
- SSL: Let's Encrypt or your own certificate · Force SSL: ON · HTTP/2: ON
- Domain Names:
- Write all of the FQDNs you defined in NPM into
APP_URL, separated by commas — otherwise CORS will reject the requests.
The backend only trusts the number of hops specified by TRUST_PROXY. In the NPM/Coolify + frontend nginx topology the typical value is 2; verify your topology against the checklist on the Security page.
Database migrations
The schema lives in backend/prisma/schema.prisma, and versioned migrations are kept under backend/prisma/migrations/.
-
Automatic on startup: both the dev and production compose files run
prisma migrate deploy. It is idempotent — it skips already-applied migrations and runs safely on every restart. -
When you change the schema, generate a new migration:
docker compose exec backend npx prisma migrate dev --name aciklayici_bir_adReview the generated SQL and include it in the commit.
Do not use prisma db push. It skips the migration history and can silently drop columns/tables with --accept-data-loss. This project used to rely on that approach; it has now moved to versioned migrations.
Baselining an existing database
If you have an already running database created with prisma db push, migrate deploy will say:
Error: P3005 The database schema is not empty.
Because Prisma sees the tables but finds no record of any applied migration. A one-time baseline is required.
1. First, verify the schema actually matches. If the output is empty, your database is identical to 0_init:
docker compose exec backend npx prisma migrate diff \
--from-url "$DATABASE_URL" \
--to-schema-datamodel prisma/schema.prisma \
--script2. If the output is empty, mark 0_init as applied (this runs no SQL, it only records it):
docker compose exec backend npx prisma migrate resolve --applied 0_init3. If the output is not empty, your database has drifted from the schema. The SQL above is the difference — do not apply it automatically. Review it, take a backup if needed, then apply it by hand or turn it into a migration. When in doubt, try it on a copy first.
Before baselining, take a database backup:
docker compose exec postgres pg_dump -U ticket ticketdb > backup.sqlConnecting with local tools
To connect with tools like DBeaver or RedisInsight, use the override that exposes Postgres/Redis only to 127.0.0.1:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d| Service | Address |
|---|---|
| Postgres | 127.0.0.1:5432 |
| Redis | 127.0.0.1:6379 |
| Backend | 127.0.0.1:4000 |
If you prefer PgAdmin:
docker compose --profile tools up -d pgadmin # http://localhost:5050Do not use docker-compose.local.yml in production — it exposes the database to the host network.
Troubleshooting
The backend shuts down as soon as it starts, with Invalid environment variables in the log
Zod validation failed. The log states which variable is at fault. The most common cause: CREDENTIALS_ENC_KEY is missing or not 64 characters, or JWT_SECRET is shorter than 32 characters.
CREDENTIALS_ENC_KEY must be 64 hex characters
Generate it with openssl rand -hex 32. Make sure no quotes or spaces remain.
Invalid key length in the password vault / passwords cannot be decrypted
CREDENTIALS_ENC_KEY is 64 characters but not valid hex (validation only checks the length), or the records were encrypted with a different key. If the old key is lost, those records cannot be recovered.
P3005 The database schema is not empty
See the Baselining an existing database section.
The backend cannot connect to Redis
The password in REDIS_PASSWORD differs from the one inside REDIS_URL. They must match. Make the same check for DB_PASSWORD / DATABASE_URL.
CORS error in the browser
APP_URL does not include the FQDN you are accessing the site from. Add it separated by a comma and restart the backend.
Live updates (SSE) do not arrive in the panel Websockets Support: ON must be set on the NPM proxy host. If there is another proxy in between, it must disable response buffering.
Frontend loads but the API returns 502
The backend cannot pass its healthcheck. Check with docker compose logs backend — usually a migration or env error.
File upload returns 413
The upload exceeds MAX_FILE_SIZE (default 25 MB) or the nginx client_max_body_size (frontend/nginx.conf, 25M). Increase both together.