Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 38 additions & 104 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,27 +1,5 @@
# Multi-stage build for combined Synkronus + Portal
# Stage 1: Build the Go application (Synkronus)
FROM golang:1.24.2-alpine AS synkronus-builder

# Install build dependencies
RUN apk add --no-cache git

# Set working directory
WORKDIR /app

# Copy go mod files
COPY synkronus/go.mod synkronus/go.sum ./

# Download dependencies
RUN go mod download

# Copy source code
COPY synkronus/ .

# Build the application
ENV CGO_ENABLED=0 GOOS=linux
RUN go build -a -ldflags='-w -s' -o synkronus ./cmd/synkronus

# Stage 2: Build the React application (Portal)
# Multi-stage build: Portal (React) -> Synkronus (Go with embedded portal) -> single runtime image
# Stage 1: Build the React application (Portal)
FROM node:24-alpine AS portal-builder

WORKDIR /app
Expand Down Expand Up @@ -67,98 +45,54 @@ RUN npm run build || true
WORKDIR /app/synkronus-portal
RUN npm run build

# Stage 3: Combine both in final image
FROM nginx:alpine
# Stage 2: Build the Go application (Synkronus) with embedded portal
FROM golang:1.24.2-alpine AS synkronus-builder

RUN apk add --no-cache git

WORKDIR /build

# Copy go mod files and download dependencies
COPY synkronus/go.mod synkronus/go.sum ./
RUN go mod download

# Copy Synkronus source
COPY synkronus/ ./

# Embed the portal build into the binary (must exist at go build time)
COPY --from=portal-builder /app/synkronus-portal/dist ./portal/dist

# Install runtime dependencies for synkronus (wget for healthcheck, su-exec for user switching)
RUN apk --no-cache add ca-certificates tzdata wget su-exec
# Build the application
ENV CGO_ENABLED=0 GOOS=linux
RUN go build -a -ldflags='-w -s' -o synkronus ./cmd/synkronus

# Stage 3: Minimal runtime image — single Go server (API + portal)
FROM alpine:3.19

# Create non-root user for synkronus
RUN apk --no-cache add ca-certificates tzdata wget

# Non-root user
RUN addgroup -g 1000 synkronus && \
adduser -D -u 1000 -G synkronus synkronus

# Copy synkronus binary and assets from builder
WORKDIR /app
COPY --from=synkronus-builder /app/synkronus /app/synkronus
COPY --from=synkronus-builder /app/openapi /app/openapi
COPY --from=synkronus-builder /app/static /app/static

# Create directories for data storage with proper permissions
# Binary and assets (portal is embedded in the binary)
COPY --from=synkronus-builder /build/synkronus /app/synkronus
COPY --from=synkronus-builder /build/openapi /app/openapi
COPY --from=synkronus-builder /build/static /app/static

RUN mkdir -p /app/data/app-bundles && \
chown -R synkronus:synkronus /app

# Copy portal built assets
COPY --from=portal-builder /app/synkronus-portal/dist /usr/share/nginx/html

# Create nginx configuration
# Proxy /api requests to local synkronus backend on port 8080
RUN echo 'server { \
listen 0.0.0.0:80; \
listen [::]:80; \
server_name _; \
root /usr/share/nginx/html; \
index index.html; \
\
# Serve portal frontend \
location / { \
try_files $uri $uri/ /index.html; \
} \
\
# Proxy API requests to local synkronus backend \
location /api { \
rewrite ^/api(.*)$ $1 break; \
proxy_pass http://127.0.0.1:8080; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
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; \
proxy_set_header X-Forwarded-Host $host; \
proxy_set_header Authorization $http_authorization; \
proxy_pass_request_headers on; \
proxy_connect_timeout 60s; \
proxy_send_timeout 60s; \
proxy_read_timeout 60s; \
proxy_buffering off; \
client_max_body_size 100M; \
} \
}' > /etc/nginx/conf.d/default.conf

# Create startup script to run both services
RUN printf '#!/bin/sh\n\
set -e\n\
\n\
# Function to handle shutdown\n\
cleanup() {\n\
echo "Shutting down..."\n\
kill -TERM "$synkronus_pid" 2>/dev/null || true\n\
nginx -s quit 2>/dev/null || true\n\
wait "$synkronus_pid" 2>/dev/null || true\n\
exit 0\n\
}\n\
\n\
# Trap signals for graceful shutdown\n\
trap cleanup SIGTERM SIGINT\n\
\n\
# Start synkronus in background as non-root user\n\
su-exec synkronus /app/synkronus &\n\
synkronus_pid=$!\n\
\n\
# Wait a moment for synkronus to start\n\
sleep 2\n\
\n\
# Start nginx in foreground (this blocks, shell remains PID 1 for signal handling)\n\
nginx -g "daemon off;"\n\
' > /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh
USER synkronus

# Go server listens on 80 so existing port mapping 8080:80 still works
ENV PORT=80

EXPOSE 80

# Health check - check synkronus health endpoint through nginx proxy
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 -O - http://127.0.0.1/api/health || exit 1

# Use custom entrypoint to run both services
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD wget --no-verbose --tries=1 -O - http://127.0.0.1/health || exit 1

CMD ["/app/synkronus"]
10 changes: 5 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Docker Compose configuration for Synkronus
# This setup includes:
# - PostgreSQL database (separate container)
# - Synkronus API + Portal (combined container)
# - Synkronus: single Go server (API + embedded portal)
#
# Usage:
# docker compose up -d # Start all services
Expand Down Expand Up @@ -31,21 +31,21 @@ services:
networks:
- synkronus-network

# Synkronus API + Portal (combined container)
# Synkronus: single Go server (API + embedded portal)
synkronus:
build:
context: .
dockerfile: Dockerfile
container_name: synkronus
ports:
- "8080:80" # Map host port 8080 to container port 80 (nginx)
- "8080:80" # Host 8080 -> container 80 (Go server)
environment:
# Database connection - must match postgres service credentials
DB_CONNECTION: "postgres://postgres:your_password@postgres:5432/synkronus?sslmode=disable"
# JWT Secret (MUST be changed in production!)
# Generate with: openssl rand -base64 32
JWT_SECRET: "your-secret-key-minimum-32-characters-long" # CHANGE THIS IN PRODUCTION!
PORT: "8080" # Internal port for Synkronus API
PORT: "80" # Go server listens on 80
LOG_LEVEL: "info" # Options: debug, info, warn, error
volumes:
- app-bundles:/app/data/app-bundles
Expand All @@ -54,7 +54,7 @@ services:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "-O", "-", "http://127.0.0.1/api/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "-O", "-", "http://127.0.0.1/health"]
interval: 30s
timeout: 3s
retries: 3
Expand Down
6 changes: 6 additions & 0 deletions synkronus/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/opendataensemble/synkronus/pkg/attachment"
"github.com/opendataensemble/synkronus/pkg/logger"
"github.com/opendataensemble/synkronus/pkg/middleware/auth"
"github.com/opendataensemble/synkronus/portal"
)

// NewRouter creates a new router with all API routes configured
Expand Down Expand Up @@ -177,5 +178,10 @@ func NewRouter(log *logger.Logger, h *handlers.Handler) http.Handler {
r.Get("/api/versions", h.GetAPIVersions) // Not implemented yet
})

// Serve embedded React portal (SPA) for all other GET requests.
// API routes above take precedence; unmatched paths get index.html for client-side routing.
portalHandler := portal.Handler()
r.Get("/*", portalHandler.ServeHTTP)

return r
}
11 changes: 11 additions & 0 deletions synkronus/portal/dist/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Synkronus</title>
</head>
<body>
<p>Synkronus portal (embed placeholder). Run the portal build and copy <code>synkronus-portal/dist</code> into <code>synkronus/portal/dist</code> for the full UI.</p>
</body>
</html>
11 changes: 11 additions & 0 deletions synkronus/portal/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package portal

import "embed"

// dist is the embedded React portal build output (synkronus-portal/dist).
// At Docker build time, the real portal build is copied into portal/dist
// before running go build. For local builds without the portal, a minimal
// placeholder index.html is committed so the Go build succeeds.
//
//go:embed all:dist
var distFS embed.FS
40 changes: 40 additions & 0 deletions synkronus/portal/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package portal

import (
"io/fs"
"net/http"
"path"
"strings"
)

// Handler returns an http.Handler that serves the embedded portal with SPA
// fallback: if the requested path is not found, index.html is served so
// client-side routing works.
func Handler() http.Handler {
// Root of the embedded FS is "dist"; sub to get content as root.
root, _ := fs.Sub(distFS, "dist")
fileServer := http.FileServer(http.FS(root))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if p == "" || p == "/" {
p = "/index.html"
}
p = strings.TrimPrefix(p, "/")
p = path.Clean(p)
if p == "." {
p = "index.html"
}
f, err := root.Open(p)
if err == nil {
defer f.Close()
stat, _ := f.Stat()
if stat != nil && !stat.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
}
// SPA fallback: serve index.html so the client router can handle the path.
r.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r)
})
}
Loading