From n00b to ZeroCool / Origen

Tu primera app fullstack en producción (sin rezarle al deploy)

Deploy real: React+Vite+Tailwind, .NET 8 Web API con Dapper y MySQL. Auth, env vars, scripts SQL y checklist para salir a producción.

Lo que vale la pena leer aquí

La neta, el primer deploy fullstack casi nunca truena por algo “de senior”. Truena por lo básico: un connection string que no existe en el server, CORS mal amarrado, un JWT con llave distinta, o porque en tu máquina sí corriste un script de MySQL… y en el VPS nadie lo aplicó.

Intro con gancho

Son las 11:47 pm. Mañana hay demo. Tu compa de diseño ya dejó el Figma “listo”, el backend “jala en local” y tú estás frente a una laptop ya medio cansada, con el ventilador a tope, pensando lo mismo que todos en su primer release: ¿en qué momento se va a romper en production por una env var?

La neta, el primer deploy fullstack casi nunca truena por algo “de senior”. Truena por lo básico: un connection string que no existe en el server, CORS mal amarrado, un JWT con llave distinta, o porque en tu máquina sí corriste un script de MySQL… y en el VPS nadie lo aplicó.

Va una guía aterrizada para sacar tu primera app fullstack a producción con un stack bien común: React con Vite + Tailwind, .NET 8 Web API, Dapper y MySQL con scripts SQL versionados. Sin magia. Sin rituales.

Qué vas a aprender

  • Cómo preparar tu app para producción sin meter “cosas raras” que nadie entiende y luego nadie mantiene.
  • Estructura mínima recomendada: frontend, API, DB, scripts y configuración.
  • Auth con JWT en .NET 8 (lo suficiente para no dejar la puerta abierta).
  • Variables de entorno y config por ambiente (local/staging/prod).
  • Deploy checklist: build, logs, CORS, healthcheck, y ese rollback mental que te salva el viernes.

Contexto práctico (la app que sí se deploya)

Pensemos en una app sencilla pero real: “Tickets” para soporte interno.

  • Frontend: React (Vite) + Tailwind
  • Backend: .NET 8 Web API
  • Data: MySQL
  • Acceso a datos: Dapper
  • Auth: JWT
  • Infra: lo mínimo viable para producción (Linux + reverse proxy). No necesitas Kubernetes para el primer golpe.

Dos escenas bien normales en MX/LATAM que influyen más de lo que aceptamos:

  1. Internet chafa: “ya quedó el build” pero se corta el SSH, se muere el upload o tu rsync se queda a medias. Por eso conviene automatizar pasos repetibles y dejar comandos listos.
  2. VPS baratón / servidor heredado: el que ya pagó la empresa “porque ahí estaba”. No asumas que tiene nada. Tu setup tiene que ser claro y depender de lo mínimo.

Paso a paso: de local a producción con el stack canónico

1) Define la estructura del repo (para que no sea talacha eterna)

Una estructura típica que aguanta bien:

/your-app
  /frontend
  /backend
    /YourApp.Api
    /YourApp.Data
    /YourApp.Domain
  /db
    /scripts
      001_init.sql
      002_add_users.sql
      003_add_tickets.sql
    applied.sql
  /ops
    nginx.conf
    systemd-yourapp.service
  README.md

Decisión práctica: si tu DB cambia “a mano” en production, ya valiste. Regla simple: cada cambio a MySQL vive como script versionado en /db/scripts.

2) Backend: .NET 8 Web API con configuración por ambiente

En producción sobrevives con:

  • Variables de entorno
  • Logs
  • Un endpoint de salud (health)

appsettings

backend/YourApp.Api/appsettings.json

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=yourapp;User=root;Password=dev;"
  },
  "Jwt": {
    "Issuer": "yourapp",
    "Audience": "yourapp",
    "Key": "DEV_ONLY_CHANGE_ME"
  },
  "Cors": {
    "AllowedOrigins": ["http://localhost:5173"]
  }
}

backend/YourApp.Api/appsettings.Production.json

{
  "Cors": {
    "AllowedOrigins": ["https://tu-dominio.com"]
  }
}

Decisión práctica: en producción, el Jwt:Key y el connection string no van hardcodeados. Se sobreescriben con variables de entorno. Si se te olvida esto, te vas a enterar en el peor momento (cuando tu jefe pida el cambio “en corto”).

Program.cs con CORS, JWT y Health

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// CORS (controlado)
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(options =>
{
    options.AddPolicy("frontend", policy =>
        policy.WithOrigins(allowedOrigins)
              .AllowAnyHeader()
              .AllowAnyMethod());
});

// JWT
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new Exception("Missing Jwt:Key");
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "yourapp";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "yourapp";

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtIssuer,
            ValidAudience = jwtAudience,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

// Healthcheck minimal
builder.Services.AddHealthChecks();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseCors("frontend");

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.MapHealthChecks("/health");

app.Run();

Tradeoff de guerra: AllowAnyOrigin te “arregla” el bug en 10 minutos… y te compra un problema de seguridad que luego te explota. Mejor arranca con lista blanca y te quitas una clase entera de sustos.

3) Acceso a datos con Dapper (y cero sorpresas)

La meta: queries claras, parámetros siempre, nada de concatenar strings con input del usuario. Si no, te estás invitando a SQL injection por ahorrar 5 minutos.

Ejemplo de repositorio con Dapper:

using System.Data;
using Dapper;

public class TicketsRepository
{
    private readonly IDbConnection _db;

    public TicketsRepository(IDbConnection db)
    {
        _db = db;
    }

    public Task<IEnumerable<TicketRow>> GetAllAsync(int userId)
    {
        const string sql = @"
            SELECT id, title, status, created_at
            FROM tickets
            WHERE created_by = @UserId
            ORDER BY created_at DESC;";

        return _db.QueryAsync<TicketRow>(sql, new { UserId = userId });
    }

    public Task<int> CreateAsync(int userId, string title)
    {
        const string sql = @"
            INSERT INTO tickets(title, status, created_by)
            VALUES (@Title, 'open', @UserId);
            SELECT LAST_INSERT_ID();";

        return _db.ExecuteScalarAsync<int>(sql, new { Title = title, UserId = userId });
    }
}

public record TicketRow(int Id, string Title, string Status, DateTime Created_At);

Decisión práctica: scripts SQL versionados para el schema, queries en Dapper limpias para la app. Esa disciplina es la que evita el clásico “en mi máquina sí” cuando el schema cambió y nadie avisó.

4) MySQL con scripts versionados (lo que te salva el deploy)

Ejemplo de db/scripts/001_init.sql:

CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE tickets (
  id INT PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(200) NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_by INT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_tickets_users FOREIGN KEY (created_by) REFERENCES users(id)
);

Y un control simple en db/applied.sql:

-- Aquí anotas manualmente qué scripts ya aplicaste en prod
-- 001_init.sql
-- 002_add_users.sql
-- 003_add_tickets.sql

Tradeoff real: esto no es una herramienta de migraciones automática. Es “lo mínimo que funciona” cuando todavía estás aprendiendo el workflow. Lo que importa: scripts numerados, revisados, aplicados en orden.

5) Auth: login y JWT sin drama

Vas a necesitar:

  • endpoint /auth/login
  • generar token
  • proteger endpoints con [Authorize]

Generación de JWT (simplificada):

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.Text;

public class JwtTokenService
{
    private readonly IConfiguration _config;

    public JwtTokenService(IConfiguration config)
    {
        _config = config;
    }

    public string CreateToken(int userId, string email)
    {
        var key = _config["Jwt:Key"]!;
        var issuer = _config["Jwt:Issuer"]!;
        var audience = _config["Jwt:Audience"]!;

        var claims = new List<Claim>
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, email)
        };

        var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
        var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: issuer,
            audience: audience,
            claims: claims,
            expires: DateTime.UtcNow.AddHours(8),
            signingCredentials: creds
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

En tu controller:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("tickets")]
public class TicketsController : ControllerBase
{
    [HttpGet]
    [Authorize]
    public IActionResult GetMine()
    {
        // Lee el userId del claim "sub" y filtra
        return Ok();
    }
}

Decisión práctica: antes del deploy, define tu política de sesión:

  • ¿8 horas está bien para un sistema interno? Casi siempre sí.
  • ¿Necesitas refresh tokens? Para tu primera app, probablemente no. Pero deja la decisión escrita para que no te la cuestionen a la mitad del sprint.
Tu primera app fullstack en producción (sin rezarle al deploy) - visual explicativa 1
Visual de apoyo: Intro con gancho

6) Frontend: build para producción y consumo de API con env vars

En Vite, usa variables con prefijo VITE_.

.env.production en frontend/:

VITE_API_BASE_URL=https://api.tu-dominio.com

Consumo:

const API = import.meta.env.VITE_API_BASE_URL;

export async function getTickets(token: string) {
  const res = await fetch(`${API}/tickets`, {
    headers: { Authorization: `Bearer ${token}` }
  });
  if (!res.ok) throw new Error("No se pudieron cargar tickets");
  return res.json();
}

Build:

cd frontend
npm ci
npm run build

Tradeoff real: si tu frontend y backend viven en dominios distintos (lo normal), CORS y cookies se vuelven tu telenovela. Con JWT en header te quitas broncas de SameSite, pero ojo: dónde guardas el token importa (ideal: en memoria; localStorage es cómodo, pero si tienes XSS te lo vuelan).

7) Deploy del backend: publish y correr como servicio

En el server:

# En tu máquina
cd backend/YourApp.Api
dotnet publish -c Release -o ./publish

# Subes publish/ al server (scp/rsync)

Ejemplo de ops/systemd-yourapp.service:

[Unit]
Description=YourApp API
After=network.target

[Service]
WorkingDirectory=/var/www/yourapp
ExecStart=/usr/bin/dotnet /var/www/yourapp/YourApp.Api.dll
Restart=always
RestartSec=5
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ConnectionStrings__Default=Server=127.0.0.1;Database=yourapp;User=youruser;Password=TU_PASSWORD;
Environment=Jwt__Key=CAMBIA_ESTA_LLAVE_LARGA
Environment=Jwt__Issuer=yourapp
Environment=Jwt__Audience=yourapp

[Install]
WantedBy=multi-user.target

Activación:

sudo systemctl daemon-reload
sudo systemctl enable yourapp
sudo systemctl start yourapp
sudo systemctl status yourapp

Consecuencia real: si no lo corres como servicio, el día que se reinicie el server (o se muera tu sesión SSH) la API se va al piso. Y sí pasa. Mucho.

8) Reverse proxy (Nginx) y CORS ya en serio

Ejemplo simple ops/nginx.conf:

server {
  listen 80;
  server_name api.tu-dominio.com;

  location / {
    proxy_pass         http://127.0.0.1:5000;
    proxy_http_version 1.1;
    proxy_set_header   Host $host;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
  }
}

En production, tu API debería contestar:

  • /health con 200
  • endpoints protegidos con 401 cuando no hay token

Tip de guerra: cuando algo falla, no adivines. Ve logs y confirma hipótesis.

sudo journalctl -u yourapp -f

Screenshots sugeridos

  • Estructura del repo en tu editor (carpetas frontend/, backend/, db/scripts/, ops/).
  • Postman/Insomnia pegándole a /auth/login y recibiendo el JWT.
  • Request a /tickets sin token (401) y luego con token (200).
  • Respuesta de /health en el dominio público.
  • systemctl status yourapp mostrando el servicio activo.
  • journalctl -u yourapp -f con logs durante un request.

Errores comunes + solución

1) “En local sí, en producción 500” por connection string

Síntoma: truena al iniciar o al primer query.
Solución: revisa variables de entorno del servicio (ConnectionStrings__Default) y que MySQL acepte conexión desde el host correcto. En el server, prueba con un cliente MySQL para validar credenciales y permisos.

2) CORS bloquea al frontend

Síntoma: en consola: CORS policy.
Solución: confirma que AllowedOrigins incluye exactamente https://tu-dominio.com (sin slash final raro) y que app.UseCors() corre antes de auth/authorization. También valida que tu frontend realmente está pegándole al dominio correcto del API.

3) JWT inválido en producción pero no en local

Síntoma: 401 aunque “acabo de loguearme”.
Solución: misma Jwt:Key entre instancias, revisa reloj del server (un desfase mata tokens), y que Issuer/Audience coincidan. Deja ClockSkew con margen mientras estabilizas.

4) MySQL schema desfasado

Síntoma: columnas “no existen”, constraints fallan, inserts truenan.
Solución: aplica scripts SQL faltantes en orden. Si no sabes cuáles, por algo existe db/applied.sql (o ya más pro: una tabla de control).

5) El frontend apunta a localhost en producción

Síntoma: el frontend en prod intenta pegarle a http://localhost:5000.
Solución: revisa .env.production, reconstruye (npm run build) y despliega estáticos del build correcto. Este bug pasa muchísimo cuando subes un build viejo por andar contra deadline.

Tu primera app fullstack en producción (sin rezarle al deploy) - visual explicativa 2
Visual de apoyo: Qué vas a aprender
  • API responde GET /health con 200 desde internet.
  • ASPNETCORE_ENVIRONMENT=Production activo.
  • Variables de entorno listas: connection string y Jwt__Key (larga y secreta).
  • CORS permite solo tu dominio real del frontend.
  • Auth funciona: login devuelve token, endpoints protegidos regresan 401 sin token.
  • Scripts SQL aplicados y registrados (nadie “arregló” la DB a mano).
  • Logs visibles con journalctl y sin secrets impresos.
  • Frontend build de producción apunta a VITE_API_BASE_URL correcto.
  • Plan de rollback mental: si truena, ¿puedes regresar al deploy anterior rápido?

FAQ

1) ¿Dónde guardo el JWT en el frontend?

Para tu primera app: no te compliques de más, pero sí entiende el riesgo. Lo más seguro es en memoria (se pierde al refrescar). Si usas localStorage, asume el tradeoff: con XSS te lo pueden robar. Si no estás seguro de tu sanitización, juega defensivo.

2) ¿Necesito HTTPS desde el día 1?

Sí. Aunque sea con un setup básico detrás de Nginx. Auth sin HTTPS es pedir que alguien capture tokens en la misma red (cafetería, cowork, oficina).

3) ¿Cómo sé si el problema es Nginx o .NET?

Prueba directo al puerto local (en el server):
curl http://127.0.0.1:5000/health

Si eso jala, el problema está en Nginx/DNS/puertos. Si no jala, revisa systemctl status y journalctl.

4) ¿Qué hago si el script SQL falla en producción?

No improvises. Lee el error, corrige el script y genera uno nuevo incremental (ej. 004_fix_index.sql). Evita reescribir historial si ya aplicaste algo; te rompes el rastro y luego no hay forma de saber qué está corriendo.

5) ¿Cuándo meto “algo más pro” (migraciones automáticas, pipelines, etc.)?

Cuando ya hiciste 1–2 deploys reales y ya sabes dónde te duele: aplicar scripts, subir builds, manejar secretos, reiniciar servicios, validar health. Primero estabiliza el workflow; luego automatizas lo que te hizo perder horas.

Siguiente episodio: teaser

Cuando tu app ya está viva en production, el juego cambia: ya no es “que funcione”, es “que se pueda operar”. Toca observabilidad ligera, logs útiles y alertas básicas.

Porque el primer bug en production no manda WhatsApp… nomás llega en viernes, 6 pm.