Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66940c2f5b | |||
| a3638fe954 |
@@ -0,0 +1,98 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
|
||||
|
||||
1. Copy the production environment template:
|
||||
```bash
|
||||
cp frontend/.env.production.example frontend/.env.production
|
||||
```
|
||||
|
||||
2. Set the API URL to use relative path:
|
||||
```env
|
||||
# frontend/.env.production
|
||||
VITE_API_URL=/api
|
||||
```
|
||||
|
||||
This ensures all API calls will use the same domain and protocol as the frontend.
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
Ensure your backend `.env` file has the correct URLs:
|
||||
```env
|
||||
# backend/.env
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
```
|
||||
|
||||
## Docker Compose Production
|
||||
|
||||
When using Docker Compose in production:
|
||||
|
||||
1. Build with production environment:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
|
||||
```
|
||||
|
||||
2. The frontend nginx configuration already includes proper proxy settings for:
|
||||
- `/api` → Backend API
|
||||
- `/photos` → Protected photo access
|
||||
- `/thumbnails` → Thumbnail images
|
||||
- `/uploads` → Public uploads (logos, favicons)
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
Example Traefik labels for docker-compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
|
||||
|
||||
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
|
||||
|
||||
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
|
||||
|
||||
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
|
||||
|
||||
## Verification
|
||||
|
||||
After deployment, verify:
|
||||
|
||||
1. Check browser console for any localhost URLs (there should be none)
|
||||
2. Verify all API calls use HTTPS
|
||||
3. Check that images load correctly with authentication
|
||||
4. Test favicon and logo display
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you see console errors about localhost:
|
||||
|
||||
1. Ensure `VITE_API_URL=/api` in frontend environment
|
||||
2. Clear browser cache
|
||||
3. Rebuild frontend with production environment:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
If images don't load:
|
||||
|
||||
1. Check that nginx proxy locations are configured
|
||||
2. Verify authentication tokens are being sent
|
||||
3. Check backend logs for authentication errors
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Production Environment Configuration
|
||||
# When running behind a reverse proxy like Traefik, use relative URLs
|
||||
|
||||
# Backend API URL
|
||||
# For production behind reverse proxy, use relative URL:
|
||||
VITE_API_URL=/api
|
||||
|
||||
# For development or if frontend/backend are on different domains:
|
||||
# VITE_API_URL=https://api.yourdomain.com
|
||||
|
||||
# Umami Analytics Configuration (optional)
|
||||
# VITE_UMAMI_URL=https://analytics.yourdomain.com
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
|
||||
# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
|
||||
@@ -79,6 +79,20 @@ server {
|
||||
proxy_cache_valid 404 1m;
|
||||
}
|
||||
|
||||
# Uploads serving proxy (logos, favicons, watermarks)
|
||||
location /uploads {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Cache uploads
|
||||
proxy_cache_valid 200 302 7d;
|
||||
proxy_cache_valid 404 1m;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-link": "^2.25.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
import { getApiBaseUrl } from './utils/url';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
@@ -48,7 +49,7 @@ function App() {
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
try {
|
||||
// Fetch public settings to check if analytics is enabled
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
const settings = await response.json();
|
||||
|
||||
// Only initialize if analytics is enabled in settings
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../config/api';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
|
||||
interface BrandingSettings {
|
||||
branding_company_name?: string;
|
||||
@@ -50,7 +51,7 @@ export const MaintenanceMode: React.FC = () => {
|
||||
src={settings?.branding_logo_url ?
|
||||
(settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`)
|
||||
: buildResourceUrl(settings.branding_logo_url))
|
||||
: '/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settings?.branding_company_name || 'PicPeak'}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Card, Input } from '../common';
|
||||
import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface ThemeCustomizerProps {
|
||||
value: ThemeConfig;
|
||||
@@ -269,7 +270,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
<div className="flex items-center gap-4">
|
||||
{localTheme.logoUrl && (
|
||||
<img
|
||||
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${localTheme.logoUrl}`}
|
||||
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : buildResourceUrl(localTheme.logoUrl)}
|
||||
alt="Custom logo"
|
||||
className="h-16 w-auto object-contain"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAuthToken } from '../../config/api';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
@@ -60,9 +61,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
|
||||
// Prepend API URL for absolute paths
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl;
|
||||
// Build full URL for the image
|
||||
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
|
||||
|
||||
console.log('Fetching authenticated image:', fullImageUrl);
|
||||
const response = await fetch(fullImageUrl, {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
|
||||
|
||||
export const DynamicFavicon: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
@@ -30,7 +31,7 @@ export const DynamicFavicon: React.FC = () => {
|
||||
link.type = 'image/png';
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`;
|
||||
: buildResourceUrl(settings.branding_favicon_url);
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl } from '../../utils/url';
|
||||
|
||||
interface ReCaptchaProps {
|
||||
onChange: (token: string | null) => void;
|
||||
@@ -20,7 +21,7 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
event: {
|
||||
@@ -122,7 +123,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
@@ -277,7 +278,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
|
||||
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
eventName?: string;
|
||||
@@ -97,7 +98,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${eventLogo}` :
|
||||
buildResourceUrl(eventLogo) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
|
||||
@@ -14,7 +14,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
|
||||
|
||||
// Create axios instance
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { setMaintenanceModeCallback } from '../config/api';
|
||||
import { getApiBaseUrl } from '../utils/url';
|
||||
|
||||
interface MaintenanceContextType {
|
||||
isMaintenanceMode: boolean;
|
||||
@@ -29,7 +30,7 @@ export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ childr
|
||||
queryKey: ['public-settings-maintenance'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
if (response.status === 503) {
|
||||
setIsMaintenanceMode(true);
|
||||
return null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
@@ -164,7 +165,7 @@ export const GalleryPage: React.FC = () => {
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
@@ -220,7 +221,7 @@ export const GalleryPage: React.FC = () => {
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
@@ -282,7 +283,7 @@ export const GalleryPage: React.FC = () => {
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<img
|
||||
src={settingsData?.branding_logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}` :
|
||||
buildResourceUrl(settingsData.branding_logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settingsData?.branding_company_name || 'PicPeak'}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../context
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService, type BrandingSettings } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
export const BrandingPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -279,7 +280,7 @@ export const BrandingPage: React.FC = () => {
|
||||
{brandingSettings.favicon_url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.favicon_url}`}
|
||||
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : buildResourceUrl(brandingSettings.favicon_url)}
|
||||
alt="Current favicon"
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
@@ -344,7 +345,7 @@ export const BrandingPage: React.FC = () => {
|
||||
{brandingSettings.watermark_logo_url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.watermark_logo_url}`}
|
||||
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : buildResourceUrl(brandingSettings.watermark_logo_url)}
|
||||
alt="Current watermark"
|
||||
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Utility functions for URL handling in production environments
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the base API URL, preferring relative URLs for production
|
||||
* @returns The API base URL
|
||||
*/
|
||||
export const getApiBaseUrl = (): string => {
|
||||
// If VITE_API_URL is explicitly set, use it
|
||||
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// In production, use relative URL
|
||||
return '/api';
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a full URL for resources (images, files, etc.)
|
||||
* In production, this will use the current origin
|
||||
* @param path - The resource path
|
||||
* @returns The full URL
|
||||
*/
|
||||
export const buildResourceUrl = (path: string): string => {
|
||||
// Remove leading slash if present
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
// If we have an explicit API URL that's not relative, use it
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) {
|
||||
const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present
|
||||
return `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
// In production (relative API), use current origin
|
||||
return `${window.location.origin}/${cleanPath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we're in production mode (using relative URLs)
|
||||
* @returns True if in production mode
|
||||
*/
|
||||
export const isProductionMode = (): boolean => {
|
||||
return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api';
|
||||
};
|
||||
Reference in New Issue
Block a user