diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f90ce74
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,23 @@
+**/.dockerignore
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
\ No newline at end of file
diff --git a/DOCKER.md b/DOCKER.md
new file mode 100644
index 0000000..a4f8c3a
--- /dev/null
+++ b/DOCKER.md
@@ -0,0 +1,8 @@
+# Running with Docker
+
+1. Copy `docker/config.example.ini` to `docker/config.ini`, and change the settings to your liking.
+2. Copy `docker/proxy-config.example.json` to `docker/proxy-config.json`, and do the same.
+3. Build with `docker compose build`
+4. Run with `docker compose up`
+
+The Caddy server will listen on `localhost:5004`.
diff --git a/Dockerfile.backend b/Dockerfile.backend
new file mode 100644
index 0000000..b7dd859
--- /dev/null
+++ b/Dockerfile.backend
@@ -0,0 +1,22 @@
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
+USER $APP_UID
+WORKDIR /app
+EXPOSE 5000
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["Foxnouns.Backend/Foxnouns.Backend.csproj", "Foxnouns.Backend/"]
+RUN dotnet restore "Foxnouns.Backend/Foxnouns.Backend.csproj"
+COPY . .
+WORKDIR "/src/Foxnouns.Backend"
+RUN dotnet build "Foxnouns.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "Foxnouns.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "Foxnouns.Backend.dll", "--migrate-and-start"]
diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj
index 8a48573..76bf470 100644
--- a/Foxnouns.Backend/Foxnouns.Backend.csproj
+++ b/Foxnouns.Backend/Foxnouns.Backend.csproj
@@ -4,6 +4,7 @@
enable
enable
true
+ Linux
@@ -44,4 +45,10 @@
+
+
+
+ .dockerignore
+
+
diff --git a/Foxnouns.Frontend/Dockerfile b/Foxnouns.Frontend/Dockerfile
new file mode 100644
index 0000000..4150c99
--- /dev/null
+++ b/Foxnouns.Frontend/Dockerfile
@@ -0,0 +1,12 @@
+FROM docker.io/node:22
+
+RUN mkdir -p /app/node_modules && chown -R node:node /app
+WORKDIR /app
+COPY package.json yarn.lock ./
+USER node
+RUN yarn
+COPY --chown=node:node . .
+
+RUN yarn build
+
+CMD ["yarn", "start"]
diff --git a/Foxnouns.Frontend/app/components/ErrorAlert.tsx b/Foxnouns.Frontend/app/components/ErrorAlert.tsx
index cedb69a..d66b6a1 100644
--- a/Foxnouns.Frontend/app/components/ErrorAlert.tsx
+++ b/Foxnouns.Frontend/app/components/ErrorAlert.tsx
@@ -1,5 +1,5 @@
import { TFunction } from "i18next";
-import Alert from "react-bootstrap/Alert";
+import { Alert } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next";
import {
ApiError,
diff --git a/Foxnouns.Frontend/app/components/nav/Navbar.tsx b/Foxnouns.Frontend/app/components/nav/Navbar.tsx
index dbccac7..7e311f2 100644
--- a/Foxnouns.Frontend/app/components/nav/Navbar.tsx
+++ b/Foxnouns.Frontend/app/components/nav/Navbar.tsx
@@ -3,9 +3,7 @@ import Meta from "~/lib/api/meta";
import { User, UserSettings } from "~/lib/api/user";
import Logo from "./Logo";
-import Nav from "react-bootstrap/Nav";
-import Navbar from "react-bootstrap/Navbar";
-import NavDropdown from "react-bootstrap/NavDropdown";
+import { Nav, Navbar, NavDropdown } from "react-bootstrap";
import { BrightnessHigh, BrightnessHighFill, MoonFill } from "react-bootstrap-icons";
import { useTranslation } from "react-i18next";
diff --git a/Foxnouns.Frontend/app/env.server.ts b/Foxnouns.Frontend/app/env.server.ts
index 882d393..2add747 100644
--- a/Foxnouns.Frontend/app/env.server.ts
+++ b/Foxnouns.Frontend/app/env.server.ts
@@ -1,3 +1,4 @@
+import "dotenv/config";
import { env } from "node:process";
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx
index 31d4d64..bd01eef 100644
--- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx
+++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx
@@ -10,10 +10,8 @@ import {
ShouldRevalidateFunction,
} from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next";
-import Form from "react-bootstrap/Form";
-import Button from "react-bootstrap/Button";
+import { Form, Button, Alert } from "react-bootstrap";
import ErrorAlert from "~/components/ErrorAlert";
-import Alert from "react-bootstrap/Alert";
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
return !actionResult;
diff --git a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx
index 1f55fda..adc4ce9 100644
--- a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx
+++ b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx
@@ -6,11 +6,7 @@ import {
ActionFunctionArgs,
} from "@remix-run/node";
import { Form as RemixForm, useActionData, useLoaderData } from "@remix-run/react";
-import Form from "react-bootstrap/Form";
-import Button from "react-bootstrap/Button";
-import ButtonGroup from "react-bootstrap/ButtonGroup";
-import ListGroup from "react-bootstrap/ListGroup";
-import { Row, Col } from "react-bootstrap";
+import { Form, Button, ButtonGroup, ListGroup, Row, Col } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import i18n from "~/i18next.server";
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
diff --git a/Foxnouns.Frontend/package.json b/Foxnouns.Frontend/package.json
index 0b1aff2..cc0c993 100644
--- a/Foxnouns.Frontend/package.json
+++ b/Foxnouns.Frontend/package.json
@@ -22,6 +22,7 @@
"compression": "^1.7.4",
"cookie": "^0.6.0",
"cross-env": "^7.0.3",
+ "dotenv": "^16.4.5",
"express": "^4.19.2",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
diff --git a/Foxnouns.Frontend/yarn.lock b/Foxnouns.Frontend/yarn.lock
index f4b109e..1d7c038 100644
--- a/Foxnouns.Frontend/yarn.lock
+++ b/Foxnouns.Frontend/yarn.lock
@@ -2554,7 +2554,7 @@ domutils@^3.0.1, domutils@^3.1.0:
domelementtype "^2.3.0"
domhandler "^5.0.3"
-dotenv@^16.0.0:
+dotenv@^16.0.0, dotenv@^16.4.5:
version "16.4.5"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..7176fc2
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,71 @@
+services:
+ backend:
+ image: backend
+ build:
+ context: .
+ dockerfile: ./Dockerfile.backend
+ environment:
+ - "Database:Url=Host=pgbouncer;Database=postgres;Username=postgres;Password=postgres"
+ - "Database:EnablePooling=false"
+ - "Host=0.0.0.0"
+ - "Port=5000"
+ restart: unless-stopped
+ volumes:
+ - ./docker/config.ini:/app/config.ini
+
+ frontend:
+ image: frontend
+ build: ./Foxnouns.Frontend
+ environment:
+ - "API_BASE=http://rate:5003/api"
+ restart: unless-stopped
+ volumes:
+ - ./docker/frontend.env:/app/.env
+
+ rate:
+ image: rate
+ build: ./rate
+ environment:
+ - "PORT=5003"
+ restart: unless-stopped
+ volumes:
+ - ./docker/proxy-config.json:/app/proxy-config.json
+
+ pgbouncer:
+ image: docker.io/edoburu/pgbouncer:latest
+ environment:
+ - "DATABASE_URL=postgres://postgres:postgres@postgres/postgres"
+ - "AUTH_TYPE=scram-sha-256"
+ - "MAX_CLIENT_CONN=100"
+ - "DEFAULT_POOL_SIZE=100"
+ - "MIN_POOL_SIZE=10"
+ restart: unless-stopped
+
+ postgres:
+ image: docker.io/postgres:16
+ command: [ "postgres",
+ "-c", "max-connections=1000",
+ "-c", "timezone=Etc/UTC",
+ "-c", "max_wal_size=1GB",
+ "-c", "min_wal_size=80MB",
+ "-c", "shared_buffers=128MB" ]
+ environment:
+ - "POSTGRES_PASSWORD=postgres"
+ restart: unless-stopped
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+
+ caddy:
+ image: docker.io/caddy:2
+ restart: unless-stopped
+ ports:
+ - "5004:80"
+ volumes:
+ - ./docker/Caddyfile:/etc/caddy/Caddyfile
+ - caddy_data:/data
+ - caddy_config:/config
+
+volumes:
+ caddy_data:
+ caddy_config:
+ postgres_data:
diff --git a/docker/Caddyfile b/docker/Caddyfile
new file mode 100644
index 0000000..6e12647
--- /dev/null
+++ b/docker/Caddyfile
@@ -0,0 +1,4 @@
+http:// {
+ reverse_proxy /api/* http://rate:5003
+ reverse_proxy http://frontend:3000
+}
\ No newline at end of file
diff --git a/docker/config.example.ini b/docker/config.example.ini
new file mode 100644
index 0000000..ba32449
--- /dev/null
+++ b/docker/config.example.ini
@@ -0,0 +1,48 @@
+;; This configuration file is specifically for Docker installations.
+;; Host, Port, and Database settings are overridden in the compose configuration.
+
+; The base *external* URL
+BaseUrl = https://pronouns.localhost
+; The base URL for media, without a trailing slash. This must be publicly accessible.
+MediaBaseUrl = https://cdn-staging.pronouns.localhost
+
+[Logging]
+; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
+LogEventLevel = Debug
+; The URL to the Seq instance (optional)
+SeqLogUrl = http://localhost:5341
+; The Sentry DSN to log to (optional)
+SentryUrl = https://examplePublicKey@o0.ingest.sentry.io/0
+; Whether to trace performance with Sentry (optional)
+SentryTracing = true
+; Percentage of performance traces to send to Sentry (optional). Defaults to 0.0 (no traces at all)
+SentryTracesSampleRate = 1.0
+; Whether to log SQL queries. Note that this is very verbose. Defaults to false.
+LogQueries = false
+; Whether metrics are enabled. If this is set to true, Foxnouns.NET will rely on Prometheus scraping metrics to update stats.
+; If set to false, a background service will be used instead. Does not actually disable the /metrics endpoint.
+; Defaults to false.
+EnableMetrics = true
+; The port the /metrics endpoint will listen on. Defaults to 5001.
+MetricsPort = 5001
+
+[Storage]
+Endpoint =
+AccessKey =
+SecretKey =
+Bucket = pronounscc
+
+[EmailAuth]
+; The address that emails will be sent from. If not set, email auth is disabled.
+From = noreply@accounts.pronouns.cc
+
+; The Coravel mail driver configuration. Keys should be self-explanatory.
+[Coravel:Mail]
+Host = localhost
+Port = 1025
+Username = smtp-username
+Password = smtp-password
+
+[DiscordAuth]
+ClientId =
+ClientSecret =
diff --git a/docker/frontend.env b/docker/frontend.env
new file mode 100644
index 0000000..e69de29
diff --git a/docker/proxy-config.example.json b/docker/proxy-config.example.json
new file mode 100644
index 0000000..278249b
--- /dev/null
+++ b/docker/proxy-config.example.json
@@ -0,0 +1,6 @@
+{
+ "port": 5003,
+ "proxy_target": "http://localhost:5000",
+ "debug": true,
+ "powered_by": "5 gay rats"
+}
diff --git a/rate/Dockerfile b/rate/Dockerfile
new file mode 100644
index 0000000..b88d028
--- /dev/null
+++ b/rate/Dockerfile
@@ -0,0 +1,16 @@
+FROM docker.io/golang:latest AS builder
+WORKDIR /build
+EXPOSE 5003
+
+COPY . ./
+RUN go mod download -x
+ENV CGO_ENABLED 0
+RUN go build -v -o rate
+
+FROM alpine:latest
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /app
+COPY --from=builder /build/rate rate
+
+CMD ["/app/rate"]
diff --git a/go.mod b/rate/go.mod
similarity index 100%
rename from go.mod
rename to rate/go.mod
diff --git a/go.sum b/rate/go.sum
similarity index 100%
rename from go.sum
rename to rate/go.sum
diff --git a/rate/main.go b/rate/main.go
index 33cccfd..6901085 100644
--- a/rate/main.go
+++ b/rate/main.go
@@ -23,6 +23,14 @@ func main() {
log.Fatalf("unmarshaling config.json: %v", err)
}
+ // Override port from environment if it's set
+ if portEnv := os.Getenv("PORT"); portEnv != "" {
+ port, err := strconv.Atoi(portEnv)
+ if err == nil {
+ hn.Port = port
+ }
+ }
+
proxyURL, err := url.Parse(hn.ProxyTarget)
if err != nil {
log.Fatalf("parsing proxy_target as URL: %v", err)