Collect Datafresco
Advanced Deployment
Summary:
This article explains how to deploy Fresco to a VPS or other infrastructure using Docker. For most users, the default deployment process will be sufficient. However, if you are an advanced user, an IT professional, or if you have specific hosting needs, this guide will explain the process.
Prerequisites:
This guide is intended for advanced technical users who are already familiar with Docker and deploying containerized apps. It provides a high-level overview of the steps required to deploy Fresco using Docker, but does not provide detailed instructions for each step.
If you are not already familiar with these technologies, we recommend following our default deployment guide.
Duration:
30 minutes
To assist users who have specialized hosting needs, we have created a Docker container which allows Fresco to be deployed across a variety of contexts, including institutional IT infrastructure, privately owned VPSs, and internal networks. This tutorial will guide you through the requirements of deploying Fresco using our docker image, and will outline the necessary steps.
Take Care!
Because of the nature of self-hosting Dockerized applications on cloud infrastructure, you are responsible for the security and maintenance of your own deployment! The Network Canvas project team has no access to your data or infrastructure by design, and updates will not be automatically applied. This may leave your data at risk.Requirements for deploying Fresco
Fresco is a Next.js application that stores interview and app data in PostgreSQL and protocol assets in an S3-compatible object store.
The supported way to self-host is with the official Docker Compose files described below. Each one bundles a Traefik reverse proxy — which obtains and renews Let's Encrypt TLS certificates automatically — together with a PostgreSQL database and the Fresco app, so you do not need to provision certificates or a database separately.
To deploy Fresco, you'll need:
- A host machine running Docker (with Docker Compose v2, i.e. the
docker composecommand), at least 1 GB of RAM, and enough disk space for your interview data (plus protocol assets if you use the bundled Garage storage). The bundled Garage configuration needs Docker Engine 25 or newer and Docker Compose v2.23.1 or newer. - A registered domain name, with DNS pointing at your host, and inbound ports 80 and 443 reachable from the Internet. Traefik uses port 80 for the Let's Encrypt HTTP challenge and port 443 to serve the app over HTTPS.
- A storage choice: bundled Garage storage (included), an external S3-compatible bucket, or UploadThing — see Choose a deployment configuration.
- (Optional) an external PostgreSQL database, if you would rather not use the bundled one.
Choose a deployment configuration
Fresco ships three ready-to-use Docker Compose files, one per storage option. Each bundles Traefik (TLS), PostgreSQL 17, and the Fresco app:
| Compose file | Storage | Use when |
|---|---|---|
docker-compose.prod.yml | Bundled Garage, self-hosted on this host | You want everything inside your own infrastructure, with no external storage dependency. |
docker-compose.external-s3.yml | An external S3-compatible service (AWS S3, Cloudflare R2, Backblaze B2, DigitalOcean Spaces, a managed MinIO, …) | You already have, or want to use, a managed S3 bucket. |
docker-compose.uploadthing.yml | UploadThing (cloud) | You want the simplest managed storage and are comfortable with assets stored off-host. |
Download the one that matches your choice; the Running Fresco section below shows how.
Environment variables
Each configuration reads its settings from a .env file placed next to the compose file. The variables common to all three are:
| Variable | Details |
|---|---|
| DATABASE_URL | A pooled connection URL for Prisma. With the bundled postgres service, point this at postgres:5432. |
| DATABASE_URL_UNPOOLED | A non-pooling connection URL for Prisma, used for database migrations during upgrades. If you have no connection pooler, use the same value as DATABASE_URL. |
| FRESCO_DOMAIN | The public domain for this deployment (e.g. study.example.com). This is the single source of truth for the deployment's domain. |
| ACME_EMAIL | Contact email for Let's Encrypt certificate notifications. |
Good to know:
You do not set PUBLIC_URL yourself. The compose files derive it automatically from FRESCO_DOMAIN (the fresco service sets PUBLIC_URL=https://${FRESCO_DOMAIN}).
If you use the bundled postgres service, it also reads .env. At a minimum set POSTGRES_PASSWORD — the PostgreSQL image refuses to start without it. POSTGRES_USER and POSTGRES_DB are optional and both default to postgres; keep POSTGRES_USER as postgres so the service's healthcheck (pg_isready -U postgres) succeeds. Make sure your DATABASE_URL / DATABASE_URL_UNPOOLED reference postgres:5432 with matching credentials. If you point at an external PostgreSQL database (such as Neon) instead, use the connection strings that service provides and you can remove the postgres service from the compose file.
A starting .env for a deployment using the bundled PostgreSQL looks like this; add your storage variables from the next section:
# Required for every deployment
FRESCO_DOMAIN=study.example.com
ACME_EMAIL=you@example.com
DATABASE_URL=postgres://postgres:CHANGE_ME@postgres:5432/postgres
DATABASE_URL_UNPOOLED=postgres://postgres:CHANGE_ME@postgres:5432/postgres
# Required when using the bundled `postgres` service
POSTGRES_PASSWORD=CHANGE_ME
# Storage: add the variables for your chosen provider (see "Storage configuration").
# Optional
#DISABLE_ANALYTICS=true
#REQUIRE_TWO_FACTOR=true
Good to know:
We use anonymous analytics to gather usage and error data from instances of Fresco to troubleshoot issues. Fresco's own events carry no participant data or interview identifiers. The one thing that can reach us is a dashboard search term: searching the participants, interviews or activity-feed table puts what you typed in the page address, and the analytics library's page-view events carry that address. See Does Fresco send data to third parties? for the full picture, and turn analytics off in Settings → Privacy or with DISABLE_ANALYTICS if that is a problem for your deployment. To disable all analytics, set DISABLE_ANALYTICS=true in your .env; this also locks the Disable Analytics setting on the settings page. Leaving it at the default (false) helps us identify bugs and improve the app.
Good to know:
If your institution requires multi-factor authentication for every administrator, set REQUIRE_TWO_FACTOR=true. Every account that signs in with a password must then set up two-factor authentication before it can use the dashboard, and cannot turn it off; accounts that sign in with a passkey are not affected. It is an environment variable rather than a dashboard setting because every Fresco account is an equal administrator. See Accounts & Security for what it enforces.
Storage configuration
How you configure storage depends on which compose file you chose. With the bundled Garage storage, the compose file configures Fresco's storage for you from two values in your .env file. With an external S3-compatible service or UploadThing, you set the storage variables in .env yourself. Environment variables take precedence over anything entered in the dashboard and lock the matching storage settings, so when you use them your .env is the single source of truth for storage.
Self-hosted storage with Garage
Used by docker-compose.prod.yml. Garage is a lightweight S3-compatible object store. It runs as a private container with no published ports. Browsers reach it through the bundled Traefik proxy via a path route (/assets/* → garage:3900) on your Fresco domain — path-style S3 URLs put the bucket name in the path. Garage sets itself up when it starts: it creates your access key and the assets bucket if they don't exist yet. Fresco is given the same key, so you don't enter any storage settings yourself, and the key stays in .env rather than in Fresco's database.
Good to know:
Earlier versions of this compose file bundled MinIO; it now bundles Garage. If your deployment already runs the bundled MinIO, don't replace your compose file with this one. Your files are not moved to Garage: they stay in the minio Docker volume, and protocols that use them stop working. Keep your current compose file, or move your files to another S3-compatible service yourself and switch to docker-compose.external-s3.yml.
This configuration needs Docker Engine 25 or newer and Docker Compose v2.23.1 or newer, because Garage's configuration is written inline in the compose file, which older versions cannot read.
1. Generate the storage secrets
Garage and Fresco both use two values from your .env. They have no defaults, and the stack will not start without them. Generate each one on your host:
echo "GK$(openssl rand -hex 12)" # GARAGE_ACCESS_KEY_ID
openssl rand -hex 32 # GARAGE_SECRET_ACCESS_KEY
Then add the results to .env:
GARAGE_ACCESS_KEY_ID=GK... # GK followed by 24 hex characters
GARAGE_SECRET_ACCESS_KEY=... # 64 hex characters
Treat both like passwords: together they give full access to your stored files.
2. Continue through the storage step
When the setup wizard reaches the storage step, S3 / S3-Compatible is already selected and its settings are filled in and locked:
| Field | Value |
|---|---|
| Endpoint URL | http://garage:3900 — the internal Docker address, used only by the containers |
| Public URL | https://<your Fresco domain> — the same domain as FRESCO_DOMAIN |
| Bucket Name | assets |
| Region | us-east-1 |
| Access Key ID | Your GARAGE_ACCESS_KEY_ID value |
| Secret Access Key | Your GARAGE_SECRET_ACCESS_KEY value, hidden |
Select Continue. These settings come from the fresco service in docker-compose.prod.yml, which takes precedence over .env, so setting STORAGE_PROVIDER or the S3_* variables in .env has no effect with this configuration.
Good to know:
The bucket name is fixed at assets. It occupies /assets/ on your Fresco domain, and assets is known not to collide with any of Fresco's own routes.
Garage does not offer server-side encryption. Protocol assets are stored in the garage-data Docker volume, so to encrypt them at rest, keep that volume on an encrypted disk. Include it in your backups alongside your database.
Replacing the storage access key
If the access key may have been exposed, replace it. Garage only adds keys and never removes them, so the old key keeps full access to your files until you delete it yourself. The replacement needs a new ID as well as a new secret: a deleted key ID can't be used again, and if you change only the secret of an existing key ID, Garage refuses to start. Fresco waits for Garage to report healthy before it starts, so in that case docker compose up stops with an error saying the garage container is unhealthy, and docker compose -f docker-compose.prod.yml logs garage names the key.
-
Generate a new
GARAGE_ACCESS_KEY_IDandGARAGE_SECRET_ACCESS_KEYwith the commands above, and note the old key ID. -
In
.env, replaceGARAGE_ACCESS_KEY_IDandGARAGE_SECRET_ACCESS_KEYwith the new values. -
Run
docker compose -f docker-compose.prod.yml up -d. Garage adds the new key at startup and gives it access to theassetsbucket, and Fresco restarts using it. -
Delete the old key:
docker compose -f docker-compose.prod.yml exec garage /garage key delete --yes <old key ID>
External S3-compatible storage
Used by docker-compose.external-s3.yml. No storage runs on the host: browsers upload assets directly to your S3 service via presigned URLs, and download them via presigned redirects.
Set these variables in .env:
STORAGE_PROVIDER=s3
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com # or https://<account-id>.r2.cloudflarestorage.com
S3_PUBLIC_URL=https://s3.us-east-1.amazonaws.com # for external services, usually THE SAME as S3_ENDPOINT
S3_BUCKET=your-existing-bucket # create the bucket first
S3_REGION=us-east-1 # use 'auto' for Cloudflare R2
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
| Variable | Details |
|---|---|
| STORAGE_PROVIDER | Set to s3 for an S3-compatible bucket. When set, the provider is fixed and the storage settings UI is locked. |
| S3_ENDPOINT | Your service's S3 API endpoint. |
| S3_PUBLIC_URL | The URL browsers use to reach the storage. For an external service this is usually the same as S3_ENDPOINT. (The bundled Garage setup above sets it to your Fresco domain.) |
| S3_BUCKET | An existing bucket name. |
| S3_REGION | The bucket's region (for example us-east-1; use auto for Cloudflare R2). |
| S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | Credentials with read and write access to the bucket. |
Bucket requirements on your S3 service:
- The bucket can — and should — stay private. Fresco never requires public read access; downloads use short-lived presigned URLs.
- CORS must allow your Fresco origin (
https://<FRESCO_DOMAIN>) forPUT(browser asset uploads) andGET(presigned asset downloads), with the usual headers (Content-Type,Content-Length) exposed.
UploadThing
Used by docker-compose.uploadthing.yml. This library provides a wrapper over Amazon S3, and requires a free UploadThing API key.
Set:
STORAGE_PROVIDER=uploadthing
UPLOADTHING_TOKEN= # from https://uploadthing.com/dashboard (API Keys)
If you intend to run your instance on a LAN or private network, at least the api/uploadthing endpoint must be accessible from the Internet. This is used as a callback for uploads to Amazon S3.
Running Fresco
-
Download the compose file for your chosen storage option onto your host. For example, for external S3:
curl -O https://raw.githubusercontent.com/complexdatacollective/Fresco/next/docker-compose.external-s3.yml -
Create a
.envfile in the same directory, containing the variables described above. -
Start the stack (substitute the compose file you downloaded):
docker compose -f docker-compose.external-s3.yml up -d
On first start, Traefik will request a Let's Encrypt certificate for FRESCO_DOMAIN. This requires DNS to already point at the host and ports 80 and 443 to be reachable from the Internet.
Pinning a version
The compose files use the ghcr.io/complexdatacollective/fresco:latest image, which always tracks the most recent release. To pin a specific version, change the image tag to the release's version number in the compose file, for example :4.1.4 (tags carry no v prefix). You can browse published images on the Fresco container registry.
Health checks
Fresco serves a liveness endpoint at /api/health for load balancers, container orchestrators, and uptime monitors. It requires no authentication. While the service is up it answers with HTTP 200 and a JSON body whose status is healthy (or degraded); when it is not, it answers 503 with unhealthy. Alongside status, the body carries only a timestamp, the list of individual checks behind the overall status, and how long the check took. It does not name the running Fresco version or the process uptime, because together they would tell a scanner which published vulnerabilities apply to an instance and whether a fix has been deployed yet. Signed-in researchers can see the running version under App Details on the Settings page.
Access Fresco
Once the stack is running, visit https://<FRESCO_DOMAIN>. You should be greeted with the onboarding wizard, which guides you through setting up Fresco — including configuring storage, unless you have already set your storage provider via environment variables.
For details on this process, refer to the configuration and setup section of the basic deployment guide.
Updating Fresco
To update, pull the latest image and recreate the stack (substitute the compose file you deployed):
docker compose -f docker-compose.external-s3.yml pull
docker compose -f docker-compose.external-s3.yml up -d
This updates the Fresco image but keeps your compose file as it is. If your deployment runs the bundled MinIO, read Self-hosted storage with Garage before you replace that file.