---
title: "Self-hosting with Docker"
description: "Run your own Reactive Resume server with Docker Compose and PostgreSQL: set up, create the first account, check health, update and back up."
---
This guide sets up your own Reactive Resume server with Docker Compose. You end up with two containers, the app and a
PostgreSQL database, reachable at an address you choose. It's the same app that runs on `https://rxresu.me`, and your data stays on
your hardware.
## Before you start
You need:
- A Linux, macOS or Windows host with Docker Engine and the Docker Compose plugin (or Docker Desktop).
- At least 1 vCPU and 1 GB of memory for the app. Allow 2 GB when PostgreSQL runs on the same host.
- Disk space for the database and uploaded pictures. 10 GB is plenty to start.
- For anything beyond a test on your own machine: a domain name and a reverse proxy that serves it over HTTPS. See
[Deployment examples](/self-hosting/examples) for Traefik, Caddy and nginx.
## How the pieces fit
The official image runs a single Node.js process on port `3000`. It serves the web app, the API, the MCP server and
uploaded files from one address. PDFs are rendered by the app itself, in the browser or on the server, so there is no
separate printer service.
| Service | Required | Purpose |
| --- | --- | --- |
| PostgreSQL | Yes | Stores accounts, resumes, cover letters and applications. Runs as its own container or managed database. |
| Upload storage | Yes | A folder mounted at `/app/data`, or an S3-compatible bucket instead. |
| SMTP server | No | Sends verification and password reset emails. Without it, emails are written to the app log. |
| Redis | No | Shares state between several app processes and lets Assistant replies survive a page reload. |
The image is published to two registries with the same tags:
- Docker Hub: `amruthpillai/reactive-resume`
- GitHub Container Registry: `ghcr.io/reactive-resume/reactive-resume`
| Tag | Contents |
| --- | --- |
| `latest` | The newest release. |
| `v6`, `v6.0`, `v6.0.0` | A major, minor or exact release. Pin `v6` to get fixes without jumping to the next major version. |
| `nightly` | The current `main` branch. For testing only. |
Images are built for `linux/amd64` and `linux/arm64`, so they run on most servers, Apple silicon, and a Raspberry
Pi 4 or newer running a 64-bit operating system.
## Set up with Docker Compose
```bash
mkdir reactive-resume && cd reactive-resume
```
The next steps create two files in it: `.env` for settings and `compose.yml` for the containers.
Create `.env` with the three required settings:
```bash .env
# The address people will use to reach your instance.
APP_URL="http://localhost:3000"
# "postgres" is the database service name in compose.yml.
POSTGRES_PASSWORD="replace-with-a-database-password"
DATABASE_URL="postgresql://postgres:replace-with-a-database-password@postgres:5432/postgres"
# Generate with: openssl rand -hex 32
AUTH_SECRET=""
```
Generate a value for `AUTH_SECRET` and a database password, and paste them in. Use the same password in
`POSTGRES_PASSWORD` and `DATABASE_URL`.
```bash Linux and macOS
openssl rand -hex 32
```
```powershell Windows
# PowerShell 7 or newer
[Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLower()
```
If you will serve the instance from a domain, set `APP_URL` to that address now, for example
`https://resume.example.com`. Every other setting is optional; the
[environment variable reference](/self-hosting/environment-variables) lists them all.
```yaml compose.yml
services:
postgres:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
retries: 10
reactive-resume:
image: amruthpillai/reactive-resume:latest
restart: unless-stopped
ports:
- "3000:3000"
env_file: .env
volumes:
- reactive_resume_data:/app/data
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
reactive_resume_data:
```
PostgreSQL is only reachable from the app container, never from outside. Uploads live in the
`reactive_resume_data` volume, so they survive when the container is recreated. The image has a built-in health
check, so Compose reports the app as `healthy` once it's ready.
To use GitHub Container Registry instead of Docker Hub, change the image to
`ghcr.io/reactive-resume/reactive-resume:latest`.
```bash
docker compose up -d
docker compose logs -f reactive-resume
```
On the first start the app creates its database tables. When the log shows a line containing
`Up and running on`, press Ctrl C to stop following the log. Within a minute, `docker compose ps` shows
the app as `healthy`.
Open your `APP_URL` in a browser and create an account; see [Creating an account](/guides/creating-an-account).
You're signed in right away. Reactive Resume also sends a verification email, but you don't have to open it to
use the app. Without SMTP settings the email isn't sent; its link is written to the app log instead:
```bash
docker compose logs reactive-resume | grep verify-email
```
Your instance is running. Next, you might want to:
- **Keep it private.** After your own account exists, add `FLAG_DISABLE_SIGNUPS="true"` to `.env` and run
`docker compose up -d`. Nobody else can sign up; existing accounts keep working.
- **Send real emails.** Add the `SMTP_*` settings from the [reference](/self-hosting/environment-variables#email).
- **Turn on AI features.** Add `ENCRYPTION_SECRET` (another `openssl rand -hex 32` value). People can then connect
their own AI provider and use the Assistant. See [Add Redis and turn on AI](/self-hosting/examples#add-redis-and-turn-on-ai).
- **Serve it over HTTPS.** Put a reverse proxy in front and set `APP_URL` to the public `https://` address. See
[Deployment examples](/self-hosting/examples).
`APP_URL` must match the address in the browser's address bar exactly, including `https://`. If it doesn't, sign-in
redirects go to the wrong place and session cookies don't stick.
## What happens at startup
Each time the app container starts, it:
1. Applies any new database migrations. Several containers starting at once take turns, so only one migrates.
2. Compares the database schema with what the migrations expect. A mismatch is logged; set `STRICT_SCHEMA_CHECK=true`
to stop instead.
3. With local storage, checks that `/app/data` (or `LOCAL_STORAGE_PATH`) is writable, and stops if it isn't.
4. Starts serving on `PORT`.
If step 1 or 3 fails, the container exits with the reason in its log. This is almost always a wrong `DATABASE_URL`, a
database that isn't reachable yet, or a storage folder the container can't write to.
## Check the health endpoint
`GET /api/health` reports whether the app can reach its database, storage and, when configured, Redis. It answers
`200` when everything is healthy and `503` when any check fails. The image's built-in health check calls it every 30
seconds.
```bash
curl http://localhost:3000/api/health
```
```json
{
"service": "reactive-resume",
"version": "6.0.0",
"status": "healthy",
"timestamp": "2026-09-30T09:00:00.000Z",
"uptime": "3600.12s",
"database": { "status": "healthy", "latencyMs": 2 },
"storage": {
"type": "local",
"status": "healthy",
"message": "Local filesystem storage is accessible and has read/write permission.",
"latencyMs": 1
}
}
```
The `redis` field appears only when `REDIS_URL` is set. When a check fails, its entry shows `"status": "unhealthy"`
and the app log has the details. Each check times out after 1.5 seconds.
Use this endpoint for load balancer or orchestrator readiness checks. Restarting the app doesn't fix a database or
storage outage, so avoid using it to kill containers.
## Update your instance
Back up the database and uploads before every update. See [Back up your data](#back-up-your-data).
```bash
docker compose pull reactive-resume
```
```bash
docker compose up -d reactive-resume
docker compose logs -f reactive-resume
```
The new version applies its migrations on startup. PostgreSQL keeps running.
Updating from v5? Read [Upgrading to v6](/self-hosting/upgrading-to-v6) first. It covers a one-time command for resumes
that still use the old style editor.
PostgreSQL is updated separately. Pulling a new app image never changes the database server's major version. To move
to a new major PostgreSQL version, dump the database, start the new version with an empty volume, and restore.
## Back up your data
Your data lives in two places. Back up both, on a schedule, and test that you can restore them.
- **Database.** Dump it with `pg_dump`:
```bash
docker compose exec -T postgres pg_dump -U postgres -d postgres --format=custom > reactive-resume.dump
```
Restore into an empty database with `pg_restore`:
```bash
docker compose exec -T postgres pg_restore -U postgres -d postgres --clean --if-exists < reactive-resume.dump
```
- **Uploads.** With local storage, copy the contents of the `reactive_resume_data` volume (or your bind-mounted
folder). With S3, turn on bucket versioning or replication in your provider.
Keep a copy of your `.env` too. Without the same `AUTH_SECRET` and `ENCRYPTION_SECRET`, a restored instance signs
everyone out, breaks two-factor authentication and loses saved AI provider keys.
## Build from the repository
The repository's own `compose.yml` builds the image from source and also starts Redis and SeaweedFS (S3-compatible
storage). It reads `.env.example` first and then your `.env`.
```bash
git clone https://github.com/reactive-resume/reactive-resume.git
cd reactive-resume
cp .env.example .env # then set APP_URL, AUTH_SECRET and ENCRYPTION_SECRET
docker compose up -d --build
```
To update, pull the latest code and rebuild only the app service:
```bash
git pull
docker compose up -d --build --no-deps reactive_resume
```
The repository file publishes PostgreSQL (`5432`) and SeaweedFS (`8333`) on the host, with default passwords. Remove
those `ports` entries or bind them to `127.0.0.1` before using it on a server reachable from the internet.
## Unraid, Synology and other homelab platforms
There is no official app template. Use your platform's generic container settings:
- Create two containers: one from `amruthpillai/reactive-resume:latest` and one from `postgres:18`. Give PostgreSQL its
own persistent volume at `/var/lib/postgresql`.
- Put both containers on the same private network, and use the PostgreSQL container's name as the host in
`DATABASE_URL`.
- Map the app's container port `3000` to any free host port.
- Map a persistent folder to `/app/data`. The app runs as the `node` user (UID 1000), so that user must be able to
write to it.
- Set `APP_URL`, `DATABASE_URL` and `AUTH_SECRET` as environment variables.
Inside the app container, `localhost` means the app container itself. It never reaches a database in another
container, so don't use `localhost` in `DATABASE_URL`.
## Troubleshooting
Read the log with `docker compose logs reactive-resume`. The last lines name the problem:
- `Invalid environment variables`: a required variable is missing or malformed. The message names it.
- `Database migrations failed` or `ECONNREFUSED`: `DATABASE_URL` is wrong or PostgreSQL isn't reachable. Check the
host name, password (URL-encode special characters) and that PostgreSQL is healthy.
- `Local storage path is not writable`: the folder mounted at `/app/data` isn't writable by UID 1000. Fix its
owner, or use a named volume.
`APP_URL` doesn't match the address you're using, or you're on `https://` while `APP_URL` says `http://`. Set
`APP_URL` to the exact public address and run `docker compose up -d`.
Emails are only sent when `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` are all set. Until then they're
written to the app log. If they're set, check `SMTP_PORT` and `SMTP_SECURE` against your provider's settings and
look for SMTP errors in the log.
Look at which entry says `unhealthy`. For `storage`, check the `/app/data` mount or your S3 settings. For `redis`,
check that Redis is running and `REDIS_URL` is correct.
Nothing persistent was mounted at `/app/data`, so uploads lived inside the old container. Mount a volume there as
in the example above. Pictures uploaded before the change are gone.
The app is using virtual-hosted addresses, which put the bucket name in front of the endpoint. MinIO, SeaweedFS
and most self-hosted services need path-style addresses. Set `S3_FORCE_PATH_STYLE="true"`.
`ENCRYPTION_SECRET` isn't set. Set it to at least 32 characters and recreate the container. A shorter value
stops the app at startup with `Invalid environment variables`.
The editor renders PDFs in the browser with WebAssembly. If you add your own Content Security Policy at the proxy,
allow `'wasm-unsafe-eval'` in `script-src`. Also check for browser extensions that block downloads.
## Related pages
- [Environment variables](/self-hosting/environment-variables): every setting, with defaults.
- [Deployment examples](/self-hosting/examples): reverse proxies, S3 storage, Redis and local AI.
- [Self-hosting with Kubernetes](/self-hosting/kubernetes): the same setup as Kubernetes manifests.
- [Checking service status](/guides/checking-service-status): the status of the hosted instance.