feat: setup monorepo infra — Go API + Vue 3 + PostgreSQL + Dockerfile Dokploy

Monorepo com apps/api (Go 1.23 + chi + pgx) e apps/web (Vue 3 + Vite + TypeScript).
Dockerfile multi-stage consolida tudo em imagem única (~25MB) para deploy no Dokploy.
Migrations rodando na inicialização; Vue embedado no binário Go via //go:embed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 20:06:37 -03:00
co-authored by Claude Sonnet 4.6
commit a3d8f363a5
25 changed files with 2131 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"financeiro-carvalho/internal/db"
"financeiro-carvalho/internal/handler"
"financeiro-carvalho/internal/migration"
"financeiro-carvalho/static"
)
func main() {
_ = godotenv.Load()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL is required")
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db connect: %v", err)
}
defer pool.Close()
if err := migration.Run(ctx, pool); err != nil {
log.Fatalf("migration: %v", err)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/health", handler.Health)
r.Route("/api", func(r chi.Router) {
// API routes added here as features are built
})
// Serve Vue SPA — non-API routes fall through to index.html
distFS, err := fs.Sub(static.FS, "dist")
if err != nil {
log.Fatalf("static embed sub: %v", err)
}
fileServer := http.FileServer(http.FS(distFS))
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path[1:]
if _, statErr := fs.Stat(distFS, path); statErr != nil {
index, readErr := fs.ReadFile(distFS, "index.html")
if readErr != nil {
http.NotFound(w, req)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(index)
return
}
fileServer.ServeHTTP(w, req)
})
fmt.Printf("server listening on :%s\n", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal(err)
}
}
+9
View File
@@ -0,0 +1,9 @@
module financeiro-carvalho
go 1.23
require (
github.com/go-chi/chi/v5 v5.2.1
github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1
)
+32
View File
@@ -0,0 +1,32 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+28
View File
@@ -0,0 +1,28 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
var pool *pgxpool.Pool
var err error
for i := range 10 {
pool, err = pgxpool.New(ctx, dsn)
if err == nil {
if pingErr := pool.Ping(ctx); pingErr == nil {
return pool, nil
}
pool.Close()
}
wait := time.Duration(i+1) * 500 * time.Millisecond
fmt.Printf("db not ready, retrying in %s...\n", wait)
time.Sleep(wait)
}
return nil, fmt.Errorf("could not connect to database after retries: %w", err)
}
+11
View File
@@ -0,0 +1,11 @@
package handler
import (
"encoding/json"
"net/http"
)
func Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
+19
View File
@@ -0,0 +1,19 @@
package migration
import (
"context"
_ "embed"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed sql/001_initial.sql
var initial string
func Run(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, initial); err != nil {
return fmt.Errorf("001_initial: %w", err)
}
return nil
}
@@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL DEFAULT '#6B7280',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS transactions (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
description TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('income', 'expense')),
source VARCHAR(10) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'import')),
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS recurring_expenses (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
expected_amount NUMERIC(12, 2) NOT NULL,
day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS import_logs (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
format VARCHAR(10) NOT NULL CHECK (format IN ('ofx', 'csv')),
imported_count INTEGER NOT NULL DEFAULT 0,
duplicate_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO categories (name, color, is_default)
VALUES
('Saúde / Insulina', '#EF4444', TRUE),
('Veleiro', '#3B82F6', TRUE),
('Jogos de Tabuleiro', '#8B5CF6', TRUE),
('Alimentação', '#F59E0B', TRUE),
('Lazer', '#10B981', TRUE),
('Freelance', '#6366F1', TRUE),
('Outros', '#6B7280', TRUE)
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
+59
View File
@@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL DEFAULT '#6B7280',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS transactions (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
description TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('income', 'expense')),
source VARCHAR(10) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'import')),
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS recurring_expenses (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
expected_amount NUMERIC(12, 2) NOT NULL,
day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS import_logs (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
format VARCHAR(10) NOT NULL CHECK (format IN ('ofx', 'csv')),
imported_count INTEGER NOT NULL DEFAULT 0,
duplicate_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO categories (name, color, is_default)
VALUES
('Saúde / Insulina', '#EF4444', TRUE),
('Veleiro', '#3B82F6', TRUE),
('Jogos de Tabuleiro', '#8B5CF6', TRUE),
('Alimentação', '#F59E0B', TRUE),
('Lazer', '#10B981', TRUE),
('Freelance', '#6366F1', TRUE),
('Outros', '#6B7280', TRUE)
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
View File
+9
View File
@@ -0,0 +1,9 @@
package static
import "embed"
// FS holds the compiled Vue frontend. Populated by the Docker build:
// the web-builder stage outputs to apps/api/static/dist before go build runs.
//
//go:embed dist
var FS embed.FS
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Financeiro Carvalho</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1610
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "financeiro-carvalho-web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
],
})
export default router
+6
View File
@@ -0,0 +1,6 @@
<template>
<main style="font-family: sans-serif; padding: 2rem; text-align: center">
<h1>Financeiro Carvalho</h1>
<p>Sistema de controle financeiro pessoal</p>
</main>
</template>
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "src/**/*.vue"],
"exclude": ["node_modules"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.app.json" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})