Skip to content

Commit 34f74c3

Browse files
edwhclaude
andcommitted
Add Fly.io deployment config and graceful Discourse/Wiki handling
- Dockerfile.fly: multi-stage build with PHP 8.2-FPM, nginx, supervisord - fly.toml / fly-mysql.toml: app and MySQL machine configs - nginx-fly.conf: nginx config with unix socket and Tigris proxy - supervisord-fly.conf: process manager for nginx, php-fpm, cron - startup.sh: background DB migrations, immediate supervisord start - TrustProxies: trust all proxies (Fly terminates TLS) - Disable Discourse/Wiki features when URLs not configured - Add try-catch around Discourse notification fetch - Remove zz-docker.conf override in Dockerfile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9d83845 commit 34f74c3

11 files changed

Lines changed: 394 additions & 14 deletions

File tree

.fly/scripts/startup.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env bash
2+
3+
# Do NOT use set -e — we must always reach supervisord at the end
4+
# so that nginx starts and the health check passes.
5+
6+
# Ensure storage directories exist
7+
mkdir -p /var/www/storage/framework/{sessions,views,cache/data}
8+
mkdir -p /var/www/storage/logs
9+
mkdir -p /var/www/bootstrap/cache
10+
chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
11+
12+
# Substitute environment variables in nginx config for Tigris proxy.
13+
if [ -n "$AWS_BUCKET" ]; then
14+
export TIGRIS_BUCKET_URL="https://${AWS_BUCKET}.fly.storage.tigris.dev"
15+
export TIGRIS_BUCKET_HOST="${AWS_BUCKET}.fly.storage.tigris.dev"
16+
envsubst '${TIGRIS_BUCKET_URL} ${TIGRIS_BUCKET_HOST}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
17+
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
18+
fi
19+
20+
# Run DB setup in a subshell so failures never prevent supervisord from starting
21+
(
22+
# Wait for MySQL to be reachable
23+
echo "Waiting for database..."
24+
DB_READY=0
25+
for i in $(seq 1 30); do
26+
if php /var/www/artisan migrate:status > /dev/null 2>&1; then
27+
DB_READY=1
28+
break
29+
fi
30+
echo " attempt $i/30 - database not ready, retrying..."
31+
sleep 2
32+
done
33+
34+
if [ "$DB_READY" = "1" ]; then
35+
echo "Database ready, running migrations..."
36+
php /var/www/artisan migrate --force || echo "WARNING: migrate failed"
37+
timeout 30 php /var/www/artisan translations:import 2>/dev/null || true
38+
else
39+
echo "WARNING: Database not reachable after 60s, skipping migrations"
40+
fi
41+
42+
# Cache config/routes/views for performance (non-fatal)
43+
php /var/www/artisan config:cache 2>/dev/null || true
44+
php /var/www/artisan route:cache 2>/dev/null || true
45+
php /var/www/artisan view:cache 2>/dev/null || true
46+
php /var/www/artisan queue:restart 2>/dev/null || true
47+
) &
48+
49+
# Start supervisord immediately (manages nginx, php-fpm, cron)
50+
# This ensures the health check can pass while DB setup runs in background
51+
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

Dockerfile.fly

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# =============================================================================
2+
# Stage 1: Build assets (Node + Composer)
3+
# =============================================================================
4+
FROM php:8.2-cli AS builder
5+
6+
# Install system dependencies for building
7+
RUN apt-get update && apt-get install -y --no-install-recommends \
8+
git zip unzip curl \
9+
libpng-dev libjpeg62-turbo-dev libfreetype6-dev libzip-dev libicu-dev libxml2-dev \
10+
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
11+
&& docker-php-ext-install pdo_mysql bcmath zip intl gd \
12+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
13+
14+
# Install xmlrpc via pecl
15+
RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable xmlrpc
16+
17+
# Install composer
18+
COPY --from=composer/composer:2-bin /composer /usr/bin/composer
19+
20+
# Install Node 18
21+
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
22+
&& apt-get install -y nodejs \
23+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
24+
25+
WORKDIR /build
26+
27+
# Copy composer files first for layer caching
28+
COPY composer.json composer.lock ./
29+
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
30+
31+
# Copy package files for npm layer caching
32+
COPY package.json package-lock.json ./
33+
RUN npm ci --legacy-peer-deps
34+
RUN npx update-browserslist-db@latest
35+
36+
# Copy all source code
37+
COPY . .
38+
39+
# Create a minimal .env for artisan commands during build (no DB needed)
40+
RUN echo "APP_KEY=base64:$(openssl rand -base64 32)" > .env && \
41+
echo "APP_ENV=production" >> .env
42+
43+
# Finish composer autoload (skip post-autoload scripts that need DB/app context)
44+
ENV COMPOSER_ALLOW_SUPERUSER=1
45+
RUN composer dump-autoload --optimize --no-dev --no-scripts
46+
47+
# Run package discovery manually
48+
RUN php artisan package:discover --ansi || true
49+
50+
# Build frontend assets
51+
# The webpack.mix.js runs `php artisan lang:js` via WebpackShellPlugin, but that
52+
# needs the full app bootstrapped. Generate translations first, then build.
53+
RUN php artisan lang:js --no-lib resources/js/translations.js 2>/dev/null || true
54+
RUN npm run production
55+
56+
# Generate swagger docs (non-fatal if it fails)
57+
RUN php artisan l5-swagger:generate 2>/dev/null || true
58+
59+
# Remove build .env (real env comes from Fly secrets at runtime)
60+
RUN rm -f .env
61+
62+
# =============================================================================
63+
# Stage 2: Production image (Nginx + PHP-FPM + Supervisord + Cron)
64+
# =============================================================================
65+
FROM php:8.2-fpm
66+
67+
# Install runtime dependencies
68+
RUN apt-get update && apt-get install -y --no-install-recommends \
69+
nginx \
70+
supervisor \
71+
cron \
72+
gettext-base \
73+
libpng-dev libjpeg62-turbo-dev libfreetype6-dev libzip-dev libicu-dev libxml2-dev \
74+
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
75+
&& docker-php-ext-install pdo_mysql bcmath zip intl gd \
76+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
77+
78+
# Install xmlrpc
79+
RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable xmlrpc
80+
81+
# Configure PHP-FPM to use unix socket
82+
RUN sed -i 's|listen = 127.0.0.1:9000|listen = /var/run/php-fpm.sock|' /usr/local/etc/php-fpm.d/www.conf && \
83+
sed -i 's|;listen.owner = www-data|listen.owner = www-data|' /usr/local/etc/php-fpm.d/www.conf && \
84+
sed -i 's|;listen.group = www-data|listen.group = www-data|' /usr/local/etc/php-fpm.d/www.conf && \
85+
sed -i 's|;listen.mode = 0660|listen.mode = 0660|' /usr/local/etc/php-fpm.d/www.conf && \
86+
rm -f /usr/local/etc/php-fpm.d/zz-docker.conf
87+
88+
# PHP production settings
89+
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/conf.d/php.ini && \
90+
echo "upload_max_filesize = 100M" >> /usr/local/etc/php/conf.d/php.ini && \
91+
echo "post_max_size = 100M" >> /usr/local/etc/php/conf.d/php.ini && \
92+
echo "memory_limit = 256M" >> /usr/local/etc/php/conf.d/php.ini
93+
94+
# Copy nginx config
95+
COPY docker/nginx-fly.conf /etc/nginx/nginx.conf
96+
97+
# Copy supervisord config
98+
COPY docker/supervisord-fly.conf /etc/supervisor/conf.d/supervisord.conf
99+
100+
# Set up cron for Laravel scheduler (every minute)
101+
RUN echo "* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1" | crontab -
102+
103+
# Set working directory
104+
WORKDIR /var/www
105+
106+
# Copy application from builder
107+
COPY --from=builder --chown=www-data:www-data /build /var/www
108+
109+
# Remove dev files not needed in production
110+
RUN rm -rf node_modules tests .git .github
111+
112+
# Ensure storage and cache directories exist
113+
RUN mkdir -p storage/framework/{sessions,views,cache/data} \
114+
storage/logs bootstrap/cache \
115+
&& chown -R www-data:www-data storage bootstrap/cache
116+
117+
# Copy startup script
118+
COPY .fly/scripts/startup.sh /usr/local/bin/startup.sh
119+
RUN chmod +x /usr/local/bin/startup.sh
120+
121+
EXPOSE 80
122+
123+
CMD ["/usr/local/bin/startup.sh"]

app/Http/Controllers/API/UserController.php

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,22 @@ public function notifications(Request $request, $id)
107107
$discourseNotifications = Cache::get('talk_notification_' . $user->username);
108108
} else {
109109
if (config('restarters.features.discourse_integration')) {
110-
$client = app('discourse-client');
111-
$response = $client->request('GET', '/notifications.json?username=' . $user->username);
112-
$talk_notifications = json_decode($response->getBody()->getContents(), true);
113-
114-
if (!empty($talk_notifications) && array_key_exists('notifications', $talk_notifications)) {
115-
foreach ($talk_notifications['notifications'] as $notification) {
116-
if ($notification['read'] !== true) {
117-
$discourseNotifications++;
110+
try {
111+
$client = app('discourse-client');
112+
$response = $client->request('GET', '/notifications.json?username=' . $user->username);
113+
$talk_notifications = json_decode($response->getBody()->getContents(), true);
114+
115+
if (!empty($talk_notifications) && array_key_exists('notifications', $talk_notifications)) {
116+
foreach ($talk_notifications['notifications'] as $notification) {
117+
if ($notification['read'] !== true) {
118+
$discourseNotifications++;
119+
}
118120
}
119-
}
120121

121-
Cache::put('talk_notification_' . $user->username, $discourseNotifications, 60);
122+
Cache::put('talk_notification_' . $user->username, $discourseNotifications, 60);
123+
}
124+
} catch (\Exception $e) {
125+
// Discourse unavailable — silently return 0 notifications
122126
}
123127
}
124128
}

app/Http/Middleware/TrustProxies.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class TrustProxies extends Middleware
1212
*
1313
* @var array<int, string>|string|null
1414
*/
15-
protected $proxies;
15+
protected $proxies = '*';
1616

1717
/**
1818
* The headers that should be used to detect proxies.

app/Providers/EventServiceProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class EventServiceProvider extends ServiceProvider
119119
public function boot()
120120
{
121121

122-
if (env('FEATURE__WIKI_INTEGRATION') === true) {
122+
if (env('FEATURE__WIKI_INTEGRATION') === true && !empty(env('WIKI_URL'))) {
123123
Event::listen('Illuminate\Auth\Events\Login', \App\Listeners\LogInToWiki::class);
124124
}
125125
}

app/Providers/MediawikiServiceProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function boot()
2828
*/
2929
public function register()
3030
{
31-
if (env('FEATURE__WIKI_INTEGRATION') === false) {
31+
if (env('FEATURE__WIKI_INTEGRATION') === false || empty(env('WIKI_URL'))) {
3232
return;
3333
}
3434

config/restarters.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
return [
44

55
'features' => [
6-
'discourse_integration' => env('FEATURE__DISCOURSE_INTEGRATION', true),
6+
'discourse_integration' => env('FEATURE__DISCOURSE_INTEGRATION', true) && !empty(env('DISCOURSE_URL')),
77
],
88

99
'wiki' => [

docker/nginx-fly.conf

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
user www-data;
2+
3+
events {
4+
worker_connections 1024;
5+
}
6+
7+
http {
8+
include /etc/nginx/mime.types;
9+
default_type application/octet-stream;
10+
11+
sendfile on;
12+
keepalive_timeout 65;
13+
14+
server {
15+
listen 80 default_server;
16+
listen [::]:80 default_server;
17+
18+
server_name _;
19+
20+
root /var/www/public;
21+
22+
index index.php;
23+
24+
charset utf-8;
25+
26+
# Security headers
27+
add_header X-Content-Type-Options "nosniff" always;
28+
add_header X-Frame-Options "SAMEORIGIN" always;
29+
add_header X-XSS-Protection "1; mode=block" always;
30+
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
31+
# HSTS - Fly.io terminates TLS, but this tells browsers to always use HTTPS
32+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
33+
34+
# Proxy /uploads/ to Tigris S3 bucket (read-only).
35+
# TIGRIS_BUCKET_URL and TIGRIS_BUCKET_HOST are substituted at startup
36+
# via envsubst in startup.sh.
37+
location /uploads/ {
38+
limit_except GET HEAD {
39+
deny all;
40+
}
41+
42+
proxy_pass ${TIGRIS_BUCKET_URL}/;
43+
proxy_set_header Host ${TIGRIS_BUCKET_HOST};
44+
proxy_hide_header x-amz-request-id;
45+
proxy_hide_header x-amz-id-2;
46+
proxy_hide_header x-amz-meta-s3cmd-attrs;
47+
proxy_intercept_errors on;
48+
expires 30d;
49+
add_header Cache-Control "public, immutable";
50+
add_header X-Content-Type-Options "nosniff" always;
51+
}
52+
53+
location / {
54+
try_files $uri $uri/ /index.php?$query_string;
55+
}
56+
57+
location = /favicon.ico { access_log off; }
58+
location = /robots.txt { access_log off; }
59+
60+
error_page 404 /index.php;
61+
62+
location ~ \.php$ {
63+
fastcgi_pass unix:/var/run/php-fpm.sock;
64+
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
65+
include fastcgi_params;
66+
fastcgi_param PHP_VALUE "upload_max_filesize=100M \n post_max_size=100M";
67+
}
68+
69+
location ~ /\.(?!well-known).* {
70+
deny all;
71+
}
72+
}
73+
}

docker/supervisord-fly.conf

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
[supervisord]
2+
nodaemon=true
3+
user=root
4+
logfile=/dev/stdout
5+
logfile_maxbytes=0
6+
pidfile=/var/run/supervisord.pid
7+
8+
[program:php-fpm]
9+
command=php-fpm --nodaemonize
10+
autostart=true
11+
autorestart=true
12+
stdout_logfile=/dev/stdout
13+
stdout_logfile_maxbytes=0
14+
stderr_logfile=/dev/stderr
15+
stderr_logfile_maxbytes=0
16+
17+
[program:nginx]
18+
command=nginx -g "daemon off;"
19+
autostart=true
20+
autorestart=true
21+
stdout_logfile=/dev/stdout
22+
stdout_logfile_maxbytes=0
23+
stderr_logfile=/dev/stderr
24+
stderr_logfile_maxbytes=0
25+
26+
[program:cron]
27+
command=cron -f
28+
autostart=true
29+
autorestart=true
30+
stdout_logfile=/dev/stdout
31+
stdout_logfile_maxbytes=0
32+
stderr_logfile=/dev/stderr
33+
stderr_logfile_maxbytes=0

fly-mysql.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
app = "restarters-db"
2+
primary_region = "lhr"
3+
4+
[build]
5+
image = "mysql:8.0"
6+
7+
[processes]
8+
app = "mysqld --datadir=/data/mysql --default-authentication-plugin=mysql_native_password"
9+
10+
[mounts]
11+
source = "mysqldata"
12+
destination = "/data"
13+
14+
[env]
15+
MYSQL_DATABASE = "restarters"
16+
MYSQL_USER = "restarters"
17+
18+
[[vm]]
19+
size = "shared-cpu-1x"
20+
memory = 2048

0 commit comments

Comments
 (0)