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:
- Internet chafa: “ya quedó el build” pero se corta el SSH, se muere el upload o tu
rsyncse queda a medias. Por eso conviene automatizar pasos repetibles y dejar comandos listos. - 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.

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:
/healthcon 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/loginy recibiendo el JWT. - Request a
/ticketssin token (401) y luego con token (200). - Respuesta de
/healthen el dominio público. systemctl status yourappmostrando el servicio activo.journalctl -u yourapp -fcon 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.

Checklist final (antes de mandar el link con confianza)
- API responde
GET /healthcon 200 desde internet. -
ASPNETCORE_ENVIRONMENT=Productionactivo. - 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
journalctly sin secrets impresos. - Frontend build de producción apunta a
VITE_API_BASE_URLcorrecto. - 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.
Idea para cerrar bien este post: toma una sola práctica de aquí y conviértela en algo que tu equipo pueda aplicar hoy.
Cuando un artículo aterriza en decisiones reales, deja de ser contenido y se vuelve ventaja.


