Deployment & Infrastructure#
This page documents how Educado actually runs in the deployed environment: the topology, the build strategies, the
domains, and the environment variables read by the code. The variable table below is kept in sync with the
process.env reads in educado-api/src; when a new one is introduced, add it here in the same commit.
For the local development setup see the Back End Getting Started and the Web Getting Started guides.
Topology#
Everything runs on a single VPS managed by Coolify 4.3.1 (version observed in the panel on 2026-08-13), a self hosted PaaS. Coolify provisions the containers, manages the environment variables and configures Traefik as the reverse proxy, which terminates TLS and routes the public hostnames to the right container. Public traffic does not reach Traefik directly: a Cloudflare Tunnel sits in front of the VPS, so Cloudflare is the first hop for every request.
Internet
|
+-----v-------------+
| Cloudflare | DNS, TLS at the edge, Tunnel to the VPS
| (Tunnel, Free) | request body limit: 100 MB
+-----+-------------+
|
(443/tcp)
|
+-----v------+
| Traefik | TLS termination, routing (managed by Coolify)
+--+------+--+
| |
educado.tominho.com api-educado.tominho.com
| |
+---------v+ +v-----------------+
| web | | api (Express) |
| nginx | | node build/index.js
+----------+ +--+-------+-------+
| |
| +-------------------+
+-----------v--+ +-v---------+ +------v------+
| PostgreSQL 16| | Redis 7.2 | | MinIO (S3) |
+--------------+ +-----+-----+ +-------------+
|
+-------v----------+
| email worker |
| node build/workers/email-worker.js
+------------------+
All application containers share the internal Coolify Docker network, so the API reaches PostgreSQL, Redis and MinIO by service name. Only Traefik publishes ports to the internet: the databases and the object storage are never exposed directly.
Cloudflare caps the request body at 100 MB
On the Cloudflare Free plan the maximum request body size is 100 MB, and that ceiling applies to every
request that crosses the tunnel, including media uploads. The API code accounts for it explicitly: the comment
above the legacy direct upload route in src/routes/media/upload-video.ts (lines 30-31) marks the endpoint as
"subject to the reverse-proxy body limit (Cloudflare Free = 100 MB)".
This is why video upload uses the chunked flow (/media/videos/init,
/media/videos/:id/parts/:partNumber, /media/videos/:id/complete): each part is bounded by the client side
chunk size of 50 MB, comfortably below the ceiling. The legacy POST /media/videos route uploads the whole file
in one request, so anything above 100 MB is rejected at the edge, before the API ever sees it. A failed large
upload with no trace in the API logs is the signature of this limit.
Domains#
| Component | Hostname |
|---|---|
| Web app | https://educado.tominho.com |
| API | https://api-educado.tominho.com |
| Swagger UI | https://api-educado.tominho.com/docs/ |
Deployed components#
| Component | Build strategy | Start command | Notes |
|---|---|---|---|
| API | Nixpacks | node build/index.js |
Express server plus Swagger UI. |
| Email worker | Nixpacks | node build/workers/email-worker.js |
Same repository and build, separate process. No public domain. |
| Web | Dockerfile | nginx -g "daemon off;" |
Multi stage build: Node 20 builds, nginx serves /dist. |
| PostgreSQL 16 | Coolify resource | managed | Persistent volume. |
| Redis 7.2 | Coolify resource | managed | Password protected. |
| MinIO | Docker Compose | managed | See the caveat below. |
MinIO is not in the Coolify 4.3.1 catalog
MinIO was removed from the Coolify service catalog in 4.3.1, so it cannot be added as a one click resource (observed in the Coolify panel on 2026-08-13; re-check when the instance is upgraded). It is deployed as a Docker Compose resource inside Coolify instead. When rebuilding the environment from scratch, do not look for it in the service list: create a Compose resource with the MinIO image, attach a persistent volume and create the media bucket before pointing the API at it.
Web build argument#
The web application is a static bundle, so the API URL is baked in at build time, not at runtime. The Dockerfile declares:
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
VITE_API_URL must be set as a build argument in Coolify (value: https://api-educado.tominho.com). Setting
it only as a runtime environment variable has no effect: the bundle will fall back to http://localhost:5001 and the
deployed site will fail to reach the API. Changing the API URL requires a rebuild, not a restart.
nginx serves the bundle with an SPA fallback (try_files $uri $uri/ /index.html) and long lived cache headers for
hashed assets.
Environment variables#
API and email worker#
Both processes are built from the same repository and take the same variables.
| Variable | Required | Description |
|---|---|---|
NODE_ENV |
Yes | Must be exactly production in the deployed environment. See the trap below. |
PORT |
No | Port the Express server listens on. Defaults to 5000 when unset (src/index.ts:41). |
POSTGRES_URI |
Yes (production) | PostgreSQL connection string. Read only when NODE_ENV === 'production'. |
POSTGRES_URI_DEV |
Yes (non production) | Connection string used whenever NODE_ENV is not production. |
ACCESS_TOKEN_SECRET |
Yes | Secret used to sign and verify JWTs. Generate with openssl rand -base64 48. |
JWT_SECRET |
No (alias) | Accepted alias of ACCESS_TOKEN_SECRET, read in src/config/jwt.ts:7 as process.env.ACCESS_TOKEN_SECRET ?? process.env.JWT_SECRET. ACCESS_TOKEN_SECRET wins when both are set. Set one, not both. |
FRONTEND_ORIGIN |
Yes (production) | Comma separated CORS allowlist, for example https://educado.tominho.com. |
S3_ENDPOINTS |
No, but it overrides S3_ENDPOINT |
Comma separated list of S3/MinIO endpoints, tried in order. Takes precedence over S3_ENDPOINT. See the trap below. |
S3_ENDPOINT |
Yes, unless S3_ENDPOINTS is set |
Single MinIO/S3 endpoint URL. Ignored whenever S3_ENDPOINTS is set and non empty. Defaults to http://localhost:9000. |
S3_REGION |
Yes | Region string, for example us-east-1. Defaults to us-east-1. |
S3_ACCESS_KEY |
Yes | Object storage access key. Defaults to minioadmin. |
S3_SECRET_KEY |
Yes | Object storage secret key. Defaults to minioadmin. |
S3_BUCKET |
Yes | Bucket holding media assets, for example educado-media. |
S3_MAX_ATTEMPTS |
No | How many times each endpoint is retried on a transient network error (EAI_AGAIN, ENOTFOUND, ECONNRESET, ETIMEDOUT). Defaults to 3. Values below 1 or unparseable fall back to the default. |
S3_RETRY_DELAY_MS |
No | Delay in milliseconds between those retries. Defaults to 150. Same fallback rule as above. |
EMAIL_API_KEY |
Yes | Resend API key. Without it the worker cannot deliver. |
EMAIL_FROM |
Yes | Sender address used on transactional email. |
SYSTEM_REVIEWER_EMAIL |
No | Identity recorded as the reviewer when a creator registration is auto approved by verified email domain (AUTO_EMAIL_DOMAIN_VERIFIED). Read in src/application/verification/email-verification-service.ts:22. Defaults to system@educado.local, an address that does not resolve, so set a real mailbox if the audit trail matters. |
CERTIFICATE_VERIFICATION_URL |
Yes (production) | Base URL printed on every issued certificate PDF. See the trap below: the built in default points outside the project. |
REDIS_HOST |
Yes | Redis hostname. Defaults to 127.0.0.1 if unset, which is wrong in a container. |
REDIS_PORT |
Yes | Redis port. Defaults to 6379. |
REDIS_PASSWORD |
Yes, when Redis has a password | Read in src/infrastructure/queue/redis.ts. Omitting it makes every queue operation fail with NOAUTH. |
Web#
| Variable | Scope | Description |
|---|---|---|
VITE_API_URL |
Build time | Base URL of the API. Must be a Docker build argument, not a runtime variable. |
Operational traps#
NODE_ENV must be exactly production
src/config/database.ts selects the connection string like this:
const isProd = () => process.env.NODE_ENV === 'production'
const postgresUri = isProd() ? process.env.POSTGRES_URI : process.env.POSTGRES_URI_DEV
Any other value, including staging, makes the API look for POSTGRES_URI_DEV. In a deployed environment that
variable does not exist, the connection fails, and the process exits with process.exit(1), which the supervisor
restarts, producing a crash loop. If the API is restarting endlessly right after a deploy, check NODE_ENV
first.
The same flag also drives HTTPS enforcement, CORS strictness and whether sequelize.sync() is allowed to alter
tables, so the value is not cosmetic.
S3_ENDPOINTS silently overrides S3_ENDPOINT
The two variables look interchangeable and are not. src/infrastructure/storage/s3/s3-client.ts:50 resolves the
endpoint list like this:
const rawEndpoints =
process.env.S3_ENDPOINTS || process.env.S3_ENDPOINT || DEFAULT_S3_ENDPOINT
S3_ENDPOINTS (plural) is read first. If it is set to anything non empty, S3_ENDPOINT (singular) is never
read at all. Editing the singular variable to repoint storage while a stale plural one is still present in the
Coolify environment changes nothing, and the failure looks like a caching or DNS problem rather than a
configuration one. When storage points at the wrong place, check S3_ENDPOINTS before S3_ENDPOINT.
The plural form takes a comma separated list. Entries are trimmed, empties are dropped and duplicates are
removed, then each endpoint is tried in order, which is what makes it useful: an internal Docker hostname first,
a public URL as fallback. Falling back to http://localhost:9000 in a container is the symptom of both being
unset.
CERTIFICATE_VERIFICATION_URL defaults to a domain the project does not own
src/application/certificates/certificate-pdf-service.ts:8 falls back to a hardcoded value:
const VERIFICATION_BASE_URL =
process.env.CERTIFICATE_VERIFICATION_URL ||
'https://educado.com/certificates/verify'
educado.com is not a domain of this project. If the variable is not configured, every certificate issued in
production is stamped with a verification link pointing at third party infrastructure, and nothing fails loudly:
the PDF is generated, the student receives it, and the link only breaks when someone tries to verify it. Worse,
already issued PDFs cannot be corrected after the fact.
Set it explicitly on the API and the worker, and check it before the first certificate is issued in any new
environment. The only verification surface that exists today is the public API route
GET /certificates/verify/:code, so https://api-educado.tominho.com/certificates/verify is a value that
actually resolves. There is no verification page in educado-web yet; once one exists, point the variable at it
instead.
Redis with a password
REDIS_PASSWORD has no default. When the Redis resource requires authentication and the variable is missing,
BullMQ connects but every command is rejected, so email jobs are enqueued and never delivered.
Schema changes in production
In production sequelize.sync() runs without alter, so new columns are not created automatically. Structural
changes must be applied deliberately before the deploy that depends on them.
Deploy checklist#
- Confirm
NODE_ENV=productionon both the API and the worker. - Confirm
POSTGRES_URI,REDIS_HOST,REDIS_PORTandREDIS_PASSWORDpoint at the Coolify resources. - Confirm the S3 variables point at MinIO and that the bucket exists. Check
S3_ENDPOINTSfirst: when it is set it overridesS3_ENDPOINTentirely. - Confirm
CERTIFICATE_VERIFICATION_URLis set, so certificates are not stamped with theeducado.comdefault. - Confirm
FRONTEND_ORIGINcontains the deployed web origin, otherwise the browser blocks every request. - For the web app, confirm
VITE_API_URLis set as a build argument and trigger a rebuild after changing it. - After deploying, check https://api-educado.tominho.com/docs/ and load the web app once to validate the API URL that was compiled into the bundle.